diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index a5d456928..f39734eb4 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,16 +1,12 @@ \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 43c1695da..14bf5f3b7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,18 +47,19 @@ dependencies { implementation 'com.jakewharton.timber:timber:4.7.1' 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 'androidx.lifecycle:lifecycle-extensions:2.2.0' // 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 (composeBom) implementation "androidx.compose.runtime:runtime" implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-viewbinding" implementation "androidx.compose.ui:ui-graphics" implementation "androidx.compose.ui:ui-tooling" implementation "androidx.compose.foundation:foundation" @@ -98,6 +99,7 @@ dependencies { testImplementation 'org.mockito:mockito-core:5.6.0' testImplementation "org.powermock:powermock-module-junit4:2.0.9" testImplementation "org.powermock:powermock-api-mockito2:2.0.9" + testImplementation("io.mockk:mockk:1.13.5") // Unit testing testImplementation 'junit:junit:4.13.2' @@ -137,7 +139,7 @@ dependencies { implementation "androidx.browser:browser:1.3.0" implementation "androidx.cardview:cardview:1.0.0" 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 'com.simplecityapps:recyclerview-fastscroll:2.0.1' @@ -226,7 +228,7 @@ android { excludes += ['META-INF/androidx.*'] } 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", "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", "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", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.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", "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", "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", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"" buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"" @@ -366,11 +370,11 @@ android { compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "17" } buildToolsVersion buildToolsVersion @@ -380,7 +384,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.3.2' + kotlinCompilerExtensionVersion '1.5.8' } namespace 'fr.free.nrw.commons' lint { diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt index b5a752ef9..50dfe8e7f 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt @@ -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!!.getCodes()[0] Intents.intended( CoreMatchers.allOf( IntentMatchers.hasAction(Intent.ACTION_VIEW), diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt index 0277b8324..c3d3dc3c3 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadCancelledTest.kt @@ -18,7 +18,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ActivityTestRule import androidx.test.rule.GrantPermissionRule 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.auth.LoginActivity import org.hamcrest.CoreMatchers diff --git a/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt index aedbcb133..647c5bbda 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditTextTest.kt @@ -17,7 +17,7 @@ class PasteSensitiveTextInputEditTextTest { @Before fun setup() { context = ApplicationProvider.getApplicationContext() - textView = PasteSensitiveTextInputEditText(context) + textView = PasteSensitiveTextInputEditText(context!!) } // this test has no real value, just % for test code coverage diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 89ed630d8..ab2edf719 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -99,7 +99,6 @@ android:exported="true" android:hardwareAccelerated="false" android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" android:windowSoftInputMode="adjustResize"> @@ -122,7 +121,7 @@ android:name=".contributions.MainActivity" android:configChanges="screenSize|keyboard|orientation" android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" /> + /> @@ -172,7 +171,7 @@ android:name=".review.ReviewActivity" android:label="@string/title_activity_review" /> {}, - spinner, - true); + spinner + ); } } diff --git a/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt index 1daadb5a1..28b01d603 100644 --- a/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt +++ b/app/src/main/java/fr/free/nrw/commons/BaseMarker.kt @@ -46,7 +46,7 @@ class BaseMarker { val drawable: Drawable = context.resources.getDrawable(drawableResId) icon = if (drawable is BitmapDrawable) { - (drawable as BitmapDrawable).bitmap + drawable.bitmap } else { val bitmap = Bitmap.createBitmap( diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java deleted file mode 100644 index c3dde9caa..000000000 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.java +++ /dev/null @@ -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 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(); - } - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt new file mode 100644 index 000000000..9ed19d686 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -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) + } + } + } + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java deleted file mode 100644 index 58801c499..000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java +++ /dev/null @@ -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; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java deleted file mode 100644 index 8c54fd292..000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java +++ /dev/null @@ -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 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); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java deleted file mode 100644 index 060a15c88..000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java +++ /dev/null @@ -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() { - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java deleted file mode 100644 index 57bb238d2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java +++ /dev/null @@ -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 { - - /** - * Wrapping CameraPosition with MutableLiveData - */ - private final MutableLiveData 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 - * @param response Response - */ - @Override - public void onResponse(final @NotNull Call call, - final Response response) { - if (response.body() == null) { - result.setValue(null); - return; - } - result.setValue(response.body()); - } - - @Override - public void onFailure(final @NotNull Call call, final @NotNull Throwable t) { - Timber.e(t); - } - - /** - * Gets live CameraPosition - * - * @return MutableLiveData - */ - public MutableLiveData getResult() { - return result; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt index 93efac7b2..025302cfd 100644 --- a/app/src/main/java/fr/free/nrw/commons/Media.kt +++ b/app/src/main/java/fr/free/nrw/commons/Media.kt @@ -3,6 +3,7 @@ package fr.free.nrw.commons import android.os.Parcelable import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.wikidata.model.page.PageTitle +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import java.util.Date import java.util.Locale @@ -124,6 +125,7 @@ class Media constructor( * Gets the categories the file falls under. * @return file categories as an ArrayList of Strings */ + @IgnoredOnParcel var addedCategories: List? = null // TODO added categories should be removed. It is added for a short fix. On category update, // categories should be re-fetched instead diff --git a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java index bed2a8755..c8cedfef1 100644 --- a/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/WelcomeActivity.java @@ -50,6 +50,7 @@ public class WelcomeActivity extends BaseActivity { copyrightBinding = PopupForCopyrightBinding.inflate(getLayoutInflater()); final View contactPopupView = copyrightBinding.getRoot(); dialogBuilder.setView(contactPopupView); + dialogBuilder.setCancelable(false); dialog = dialogBuilder.create(); dialog.show(); diff --git a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt index de716db99..1dcf93edf 100644 --- a/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/actions/ThanksClient.kt @@ -3,7 +3,7 @@ package fr.free.nrw.commons.actions import fr.free.nrw.commons.CommonsApplication import fr.free.nrw.commons.auth.csrf.CsrfTokenClient 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 javax.inject.Inject import javax.inject.Named @@ -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 } diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java deleted file mode 100644 index 53903769d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt new file mode 100644 index 000000000..aa86cd0d8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt @@ -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 +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java deleted file mode 100644 index 0b6d1831c..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.java +++ /dev/null @@ -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); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt new file mode 100644 index 000000000..330792fa7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt @@ -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" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java deleted file mode 100644 index 7c2f4a334..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt new file mode 100644 index 000000000..c9eb7d2f1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt @@ -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() + .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) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java deleted file mode 100644 index be90bb4bb..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt new file mode 100644 index 000000000..5b48ecd8f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt @@ -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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java deleted file mode 100644 index 643725604..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt new file mode 100644 index 000000000..367989f14 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticator.kt @@ -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?, + 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 + ) = 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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java deleted file mode 100644 index bb41f27aa..000000000 --- a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.java +++ /dev/null @@ -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(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt new file mode 100644 index 000000000..852536a48 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/auth/WikiAccountAuthenticatorService.kt @@ -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 +} diff --git a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt index 48ca37cf0..2a799c847 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt @@ -237,7 +237,7 @@ class LoginClient( .subscribe({ response: MwQueryResponse? -> loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 loginResult.groups = - response?.query()?.getUserResponse(userName)?.groups ?: emptySet() + response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet() cb.success(loginResult) }, { caught: Throwable -> Timber.e(caught, "Login succeeded but getting group information failed. ") diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java deleted file mode 100644 index 71690c5e2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt new file mode 100644 index 000000000..e0ade52fe --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkPages.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java index 70c370836..6788a8290 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/items/BookmarkItemsDao.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.bookmarks.items; +import android.annotation.SuppressLint; import android.content.ContentProviderClient; import android.content.ContentValues; import android.database.Cursor; @@ -134,6 +135,7 @@ public class BookmarkItemsDao { * @param cursor : Object for storing database data * @return DepictedItem */ + @SuppressLint("Range") DepictedItem fromCursor(final Cursor cursor) { final String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)); final String description diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java index 850b953e9..fe4f603f4 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.bookmarks.locations; +import android.annotation.SuppressLint; import android.content.ContentProviderClient; import android.content.ContentValues; import android.database.Cursor; @@ -146,6 +147,7 @@ public class BookmarkLocationsDao { return false; } + @SuppressLint("Range") @NonNull Place fromCursor(final Cursor cursor) { final LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)), diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java index 65d0e45a8..f5ce556c4 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java @@ -9,6 +9,7 @@ import android.view.ViewGroup; import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; @@ -33,6 +34,23 @@ public class BookmarkLocationsFragment extends DaggerFragment { @Inject BookmarkLocationsDao bookmarkLocationDao; @Inject CommonPlaceClickActions commonPlaceClickActions; private PlaceAdapter adapter; + + private final ActivityResultLauncher cameraPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> { + contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks); + }); + }); + + private final ActivityResultLauncher galleryPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> { + contributionController.onPictureReturnedFromGallery(result, requireActivity(), callbacks); + }); + }); + private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback>() { @Override public void onActivityResult(Map result) { @@ -45,7 +63,7 @@ public class BookmarkLocationsFragment extends DaggerFragment { contributionController.locationPermissionCallback.onLocationPermissionGranted(); } else { if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { - contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher); + contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); } else { 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; }, commonPlaceClickActions, - inAppCameraLocationPermissionLauncher + inAppCameraLocationPermissionLauncher, + galleryPickLauncherForResult, + cameraPickLauncherForResult ); 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 public void onDestroy() { super.onDestroy(); diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java index a56a39ba2..c214ae996 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesDao.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.bookmarks.pictures; +import android.annotation.SuppressLint; import android.content.ContentProviderClient; import android.content.ContentValues; import android.database.Cursor; @@ -150,6 +151,7 @@ public class BookmarkPicturesDao { return false; } + @SuppressLint("Range") @NonNull Bookmark fromCursor(Cursor cursor) { String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_MEDIA_NAME)); diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java deleted file mode 100644 index 4d1eb33ce..000000000 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java +++ /dev/null @@ -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(); - } - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt new file mode 100644 index 000000000..7a4720177 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt @@ -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" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java deleted file mode 100644 index 51c841451..000000000 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java +++ /dev/null @@ -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 { - 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 campaigns = okHttpJsonApiClient.getCampaigns(); - campaigns.observeOn(mainThreadScheduler) - .subscribeOn(ioScheduler) - .subscribeWith(new SingleObserver() { - - @Override public void onSubscribe(Disposable d) { - disposable = d; - } - - @Override public void onSuccess(CampaignResponseDTO campaignResponseDTO) { - List 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"); - } - }); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt new file mode 100644 index 000000000..4743e0e54 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt @@ -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 { + 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) { + 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? { + 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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java deleted file mode 100644 index a1e79cca6..000000000 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java +++ /dev/null @@ -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); -} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt new file mode 100644 index 000000000..62a19aaac --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt @@ -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?) +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt index 1cb50b8f3..7e6fee2fc 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt @@ -78,7 +78,13 @@ class CategoriesModel // Newly used category... 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() categoryDao.save(category) diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.java b/app/src/main/java/fr/free/nrw/commons/category/Category.java deleted file mode 100644 index 32bba67ba..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/Category.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.kt b/app/src/main/java/fr/free/nrw/commons/category/Category.kt new file mode 100644 index 000000000..e4bfb957a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/Category.kt @@ -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++ + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java deleted file mode 100644 index df99b4060..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java +++ /dev/null @@ -1,5 +0,0 @@ -package fr.free.nrw.commons.category; - -public interface CategoryClickedListener { - void categoryClicked(CategoryItem item); -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt new file mode 100644 index 000000000..ef4ec3d39 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.category + +interface CategoryClickedListener { + fun categoryClicked(item: CategoryItem) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt index 64463d826..5571e0ea7 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClient.kt @@ -124,7 +124,9 @@ class CategoryClient }.map { it .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 { CategoryItem( it.title().replace(CATEGORY_PREFIX, ""), diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java deleted file mode 100644 index 01793ca95..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java +++ /dev/null @@ -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; - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt new file mode 100644 index 000000000..ddd7f5ae4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt @@ -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?, selection: String?, + selectionArgs: Array?, 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?): Int { + // Not implemented + return 0 + } + + @SuppressWarnings("ConstantConditions") + override fun bulkInsert(uri: Uri, values: Array): 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?): 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}") + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java deleted file mode 100644 index b638fc508..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java +++ /dev/null @@ -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 clientProvider; - - @Inject - public CategoryDao(@Named("category") Provider 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 recentCategories(int limit) { - List 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; - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt new file mode 100644 index 000000000..3371da184 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt @@ -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 +) { + + 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 { + val items = ArrayList() + 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) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java deleted file mode 100644 index 457bd48c6..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java +++ /dev/null @@ -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 fragmentList = new ArrayList<>(); - List 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(); - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt new file mode 100644 index 000000000..ba1fcfdae --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt @@ -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() + val titleList = mutableListOf() + 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() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.java deleted file mode 100644 index 393a8dba4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.java +++ /dev/null @@ -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 makeCategoryEdit(Context context, Media media, List 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 addCategory(Media media, List 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 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 categories); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt new file mode 100644 index 000000000..22cb19172 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt @@ -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, + wikiText: String + ): Single { + 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?, + wikiText: String + ): Observable { + 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?): Boolean + } + + companion object { + const val NOTIFICATION_EDIT_CATEGORY = 1 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java deleted file mode 100644 index 5b85a2f81..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java +++ /dev/null @@ -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); -} - - diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt new file mode 100644 index 000000000..9fe811f74 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.category + +interface CategoryImagesCallback { + fun viewPagerNotifyDataSetChanged() + + fun onMediaClicked(position: Int) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java deleted file mode 100644 index af28ad07d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java +++ /dev/null @@ -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 data; - - public GridViewAdapter(Context context, int layoutResourceId, List 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 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 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); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt new file mode 100644 index 000000000..5dbcc59fd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt @@ -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? +) : ArrayAdapter(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) { + 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?): 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(R.id.categoryImageView) + val fileName = view.findViewById(R.id.categoryImageTitle) + val uploader = view.findViewById(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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java deleted file mode 100644 index 5899d5905..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.category; - -import java.util.List; - -public interface OnCategoriesSaveHandler { - void onCategoriesSave(List categories); -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt new file mode 100644 index 000000000..68200992c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.category + +interface OnCategoriesSaveHandler { + fun onCategoriesSave(categories: List) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt index 286abb97a..3f7bffe91 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt @@ -3,6 +3,7 @@ package fr.free.nrw.commons.contributions import androidx.paging.PagedList.BoundaryCallback import fr.free.nrw.commons.auth.SessionManager 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 io.reactivex.Scheduler import io.reactivex.disposables.CompositeDisposable @@ -20,7 +21,7 @@ class ContributionBoundaryCallback private val repository: ContributionsRepository, private val sessionManager: SessionManager, private val mediaClient: MediaClient, - @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler, + @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler, ) : BoundaryCallback() { private val compositeDisposable: CompositeDisposable = CompositeDisposable() var userName: String? = null diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java index 1251d1027..65604a7e0 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java @@ -7,6 +7,7 @@ import android.app.Activity; import android.content.Context; import android.content.Intent; import android.widget.Toast; +import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; @@ -45,7 +46,8 @@ public class ContributionController { private boolean isInAppCameraUpload; public LocationPermissionCallback locationPermissionCallback; private LocationPermissionsHelper locationPermissionsHelper; - LiveData> failedAndPendingContributionList; + // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + // LiveData> failedAndPendingContributionList; LiveData> pendingContributionList; LiveData> failedContributionList; @@ -64,10 +66,11 @@ public class ContributionController { * Check for permissions and initiate camera click */ public void initiateCameraPick(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher) { + ActivityResultLauncher inAppCameraLocationPermissionLauncher, + ActivityResultLauncher resultLauncher) { boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true); if (!useExtStorage) { - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); return; } @@ -75,17 +78,17 @@ public class ContributionController { () -> { if (defaultKvStore.getBoolean("inAppCameraFirstRun")) { defaultKvStore.putBoolean("inAppCameraFirstRun", false); - askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher); + askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher, resultLauncher); } else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) { createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher); + inAppCameraLocationPermissionLauncher, resultLauncher); } else { - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); } }, R.string.storage_permission_title, R.string.write_storage_permission_rationale, - PermissionUtils.PERMISSIONS_STORAGE); + PermissionUtils.getPERMISSIONS_STORAGE()); } /** @@ -94,7 +97,8 @@ public class ContributionController { * @param activity */ private void createDialogsAndHandleLocationPermissions(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher) { + ActivityResultLauncher inAppCameraLocationPermissionLauncher, + ActivityResultLauncher resultLauncher) { locationPermissionCallback = new LocationPermissionCallback() { @Override public void onLocationPermissionDenied(String toastMessage) { @@ -103,16 +107,16 @@ public class ContributionController { toastMessage, Toast.LENGTH_LONG ).show(); - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); } @Override public void onLocationPermissionGranted() { if (!locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { 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 { - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); } } }; @@ -135,9 +139,10 @@ public class ContributionController { * @param activity Activity reference * @param dialogTextResource Resource id of text to be shown in dialog * @param toastTextResource Resource id of text to be shown in toast + * @param resultLauncher */ private void showLocationOffDialog(Activity activity, int dialogTextResource, - int toastTextResource) { + int toastTextResource, ActivityResultLauncher resultLauncher) { DialogUtil .showAlertDialog(activity, activity.getString(R.string.ask_to_turn_location_on), @@ -148,25 +153,26 @@ public class ContributionController { () -> { Toast.makeText(activity, activity.getString(toastTextResource), Toast.LENGTH_LONG).show(); - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); } ); } public void handleShowRationaleFlowCameraLocation(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher) { + ActivityResultLauncher inAppCameraLocationPermissionLauncher, + ActivityResultLauncher resultLauncher) { DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title), activity.getString(R.string.in_app_camera_location_permission_rationale), activity.getString(android.R.string.ok), activity.getString(android.R.string.cancel), () -> { createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher); + inAppCameraLocationPermissionLauncher, resultLauncher); }, () -> locationPermissionCallback.onLocationPermissionDenied( activity.getString(R.string.in_app_camera_location_permission_denied)), - null, - false); + null + ); } /** @@ -181,7 +187,8 @@ public class ContributionController { * @param activity */ private void askUserToAllowLocationAccess(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher) { + ActivityResultLauncher inAppCameraLocationPermissionLauncher, + ActivityResultLauncher resultLauncher) { DialogUtil.showAlertDialog(activity, activity.getString(R.string.in_app_camera_location_permission_title), activity.getString(R.string.in_app_camera_location_access_explanation), @@ -190,47 +197,45 @@ public class ContributionController { () -> { defaultKvStore.putBoolean("inAppCameraLocationPref", true); createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher); + inAppCameraLocationPermissionLauncher, resultLauncher); }, () -> { ViewUtil.showLongToast(activity, R.string.in_app_camera_location_permission_denied); defaultKvStore.putBoolean("inAppCameraLocationPref", false); - initiateCameraUpload(activity); + initiateCameraUpload(activity, resultLauncher); }, - null, - true); + null + ); } /** * Initiate gallery picker */ - public void initiateGalleryPick(final Activity activity, final boolean allowMultipleUploads) { - initiateGalleryUpload(activity, allowMultipleUploads); + public void initiateGalleryPick(final Activity activity, ActivityResultLauncher resultLauncher, final boolean allowMultipleUploads) { + initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads); } /** * Initiate gallery picker with permission */ - public void initiateCustomGalleryPickWithPermission(final Activity activity) { + public void initiateCustomGalleryPickWithPermission(final Activity activity, ActivityResultLauncher resultLauncher) { setPickerConfiguration(activity, true); PermissionUtils.checkPermissionsAndPerformAction(activity, - () -> FilePicker.openCustomSelector(activity, 0), + () -> FilePicker.openCustomSelector(activity, resultLauncher, 0), R.string.storage_permission_title, R.string.write_storage_permission_rationale, - PermissionUtils.PERMISSIONS_STORAGE); + PermissionUtils.getPERMISSIONS_STORAGE()); } /** * Open chooser for gallery uploads */ - private void initiateGalleryUpload(final Activity activity, + private void initiateGalleryUpload(final Activity activity, ActivityResultLauncher resultLauncher, final boolean allowMultipleUploads) { setPickerConfiguration(activity, allowMultipleUploads); - boolean openDocumentIntentPreferred = defaultKvStore.getBoolean( - "openDocumentPhotoPickerPref", true); - FilePicker.openGallery(activity, 0, openDocumentIntentPreferred); + FilePicker.openGallery(activity, resultLauncher, 0, isDocumentPhotoPickerPreferred()); } /** @@ -247,22 +252,43 @@ public class ContributionController { /** * Initiate camera upload by opening camera */ - private void initiateCameraUpload(Activity activity) { + private void initiateCameraUpload(Activity activity, ActivityResultLauncher resultLauncher) { setPickerConfiguration(activity, false); if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) { locationBeforeImageCapture = locationManager.getLastLocation(); } 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. */ - public void handleActivityResult(Activity activity, int requestCode, int resultCode, - Intent data) { - FilePicker.handleActivityResult(requestCode, resultCode, data, activity, - new DefaultCallback() { + public void handleActivityResultWithCallback(Activity activity, FilePicker.HandleActivityResult handleActivityResult) { + + handleActivityResult.onHandleActivityResult(new DefaultCallback() { @Override 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 * then it populates the `failedAndPendingContributionList`. **/ - void getFailedAndPendingContributions() { - final PagedList.Config pagedListConfig = - (new PagedList.Config.Builder()) - .setPrefetchDistance(50) - .setPageSize(10).build(); - Factory factory; - factory = repository.fetchContributionsWithStates( - Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, - Contribution.STATE_PAUSED, Contribution.STATE_FAILED)); - - LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, - pagedListConfig); - failedAndPendingContributionList = livePagedListBuilder.build(); - } +// void getFailedAndPendingContributions() { +// final PagedList.Config pagedListConfig = +// (new PagedList.Config.Builder()) +// .setPrefetchDistance(50) +// .setPageSize(10).build(); +// Factory factory; +// factory = repository.fetchContributionsWithStates( +// Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, +// Contribution.STATE_PAUSED, Contribution.STATE_FAILED)); +// +// LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, +// pagedListConfig); +// failedAndPendingContributionList = livePagedListBuilder.build(); +// } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java index a840aa8e1..ca9677691 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java @@ -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.nearby.fragments.NearbyParentFragment.WLM_URL; 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.formatDistanceBetween; @@ -23,12 +22,10 @@ import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; -import android.view.MenuItem.OnMenuItemClickListener; import android.view.View; import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; import androidx.activity.result.ActivityResultCallback; @@ -39,7 +36,6 @@ import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager.OnBackStackChangedListener; import androidx.fragment.app.FragmentTransaction; -import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.databinding.FragmentContributionsBinding; @@ -293,7 +289,7 @@ public class ContributionsFragment }); } 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 * contributions. */ - public void setUploadIconVisibility() { - contributionController.getFailedAndPendingContributions(); - contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(), - list -> { - updateUploadIcon(list.size()); - }); - } +// public void setUploadIconVisibility() { +// contributionController.getFailedAndPendingContributions(); +// contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(), +// list -> { +// updateUploadIcon(list.size()); +// }); +// } /** * 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) { setNotificationCount(); fetchCampaigns(); - setUploadIconVisibility(); + // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + // setUploadIconVisibility(); setUploadIconCount(); } } @@ -570,8 +568,8 @@ public class ContributionsFragment getString(R.string.nearby_card_permission_explanation), this::requestLocationPermission, this::displayYouWontSeeNearbyMessage, - checkBoxView, - false); + checkBoxView + ); } 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. */ - public void updateUploadIcon(int count) { - if (pendingUploadsImageView != null) { - if (count != 0) { - pendingUploadsImageView.setVisibility(View.VISIBLE); - } else { - pendingUploadsImageView.setVisibility(View.GONE); - } - } - } +// public void updateUploadIcon(int count) { +// if (pendingUploadsImageView != null) { +// if (count != 0) { +// pendingUploadsImageView.setVisibility(View.VISIBLE); +// } else { +// pendingUploadsImageView.setVisibility(View.GONE); +// } +// } +// } /** * Replace whatever is in the current contributionsFragmentContainer view with diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java index 53c91534e..509d1eb95 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java @@ -6,6 +6,7 @@ import static fr.free.nrw.commons.di.NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_ import android.Manifest.permission; import android.content.Context; +import android.content.Intent; import android.content.res.Configuration; import android.net.Uri; import android.os.Bundle; @@ -20,6 +21,7 @@ import android.widget.LinearLayout; import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -96,6 +98,30 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl private int contributionsSize; private String userName; + private final ActivityResultLauncher galleryPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks); + }); + }); + + private final ActivityResultLauncher customSelectorLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromCustomSelector(result, requireActivity(), callbacks); + }); + }); + + private final ActivityResultLauncher cameraPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks); + }); + }); + private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult( new RequestMultiplePermissions(), new ActivityResultCallback>() { @@ -111,7 +137,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl } else { if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { controller.handleShowRationaleFlowCameraLocation(getActivity(), - inAppCameraLocationPermissionLauncher); + inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); } else { controller.locationPermissionCallback.onLocationPermissionDenied( getActivity().getString( @@ -322,7 +348,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl private void setListeners() { binding.fabPlus.setOnClickListener(view -> animateFAB(isFabOpen)); binding.fabCamera.setOnClickListener(view -> { - controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher); + controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); animateFAB(isFabOpen); }); binding.fabCamera.setOnLongClickListener(view -> { @@ -330,7 +356,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl return true; }); binding.fabGallery.setOnClickListener(view -> { - controller.initiateGalleryPick(getActivity(), true); + controller.initiateGalleryPick(getActivity(), galleryPickLauncherForResult, true); animateFAB(isFabOpen); }); binding.fabGallery.setOnLongClickListener(view -> { @@ -343,7 +369,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl * Launch Custom Selector. */ protected void launchCustomSelector() { - controller.initiateCustomGalleryPickWithPermission(getActivity()); + controller.initiateCustomGalleryPickWithPermission(getActivity(), customSelectorLauncherForResult); animateFAB(isFabOpen); } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java index 42495889d..735ff63d4 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java @@ -1,5 +1,7 @@ package fr.free.nrw.commons.contributions; +import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; + import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.paging.DataSource; @@ -34,7 +36,7 @@ public class ContributionsListPresenter implements UserActionListener { final ContributionBoundaryCallback contributionBoundaryCallback, final ContributionsRemoteDataSource contributionsRemoteDataSource, final ContributionsRepository repository, - @Named(CommonsApplicationModule.IO_THREAD) final Scheduler ioThreadScheduler) { + @Named(IO_THREAD) final Scheduler ioThreadScheduler) { this.contributionBoundaryCallback = contributionBoundaryCallback; this.repository = repository; this.ioThreadScheduler = ioThreadScheduler; diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java index 297a66616..495a4bc64 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java @@ -1,5 +1,6 @@ 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 androidx.work.ExistingWorkPolicy; @@ -31,7 +32,7 @@ public class ContributionsPresenter implements UserActionListener { @Inject ContributionsPresenter(ContributionsRepository repository, UploadRepository uploadRepository, - @Named(CommonsApplicationModule.IO_THREAD) Scheduler ioThreadScheduler) { + @Named(IO_THREAD) Scheduler ioThreadScheduler) { this.contributionsRepository = repository; this.uploadRepository = uploadRepository; this.ioThreadScheduler = ioThreadScheduler; diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt index 346c83b34..e8ff01b3e 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRemoteDataSource.kt @@ -1,7 +1,7 @@ package fr.free.nrw.commons.contributions 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 io.reactivex.Scheduler import io.reactivex.disposables.CompositeDisposable @@ -16,7 +16,7 @@ class ContributionsRemoteDataSource @Inject constructor( private val mediaClient: MediaClient, - @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler, + @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler, ) : ItemKeyedDataSource() { private val compositeDisposable: CompositeDisposable = CompositeDisposable() var userName: String? = null diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java index 54d3e9681..03027f287 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java @@ -1,13 +1,10 @@ package fr.free.nrw.commons.contributions; -import android.Manifest.permission; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; @@ -16,10 +13,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; -import androidx.viewpager.widget.ViewPager; import androidx.work.ExistingWorkPolicy; import fr.free.nrw.commons.databinding.MainBinding; -import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.R; import fr.free.nrw.commons.WelcomeActivity; 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.settings.SettingsFragment; 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.worker.WorkRequestHelper; import fr.free.nrw.commons.utils.PermissionUtils; @@ -420,7 +414,7 @@ public class MainActivity extends BaseActivity return true; case R.id.notifications: // Starts notification activity on click to notification icon - NotificationActivity.startYourself(this, "unread"); + NotificationActivity.Companion.startYourself(this, "unread"); return true; default: 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 protected void onResume() { super.onResume(); diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt index 77e52e1db..86cda2cf3 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt @@ -22,7 +22,7 @@ class WikipediaInstructionsDialogFragment : DialogFragment() { ) = DialogAddToWikipediaInstructionsBinding .inflate(inflater, container, false) .apply { - val contribution: Contribution? = arguments!!.getParcelable(ARG_CONTRIBUTION) + val contribution: Contribution? = requireArguments().getParcelable(ARG_CONTRIBUTION) tvWikicode.setText(contribution?.media?.wikiCode) instructionsCancel.setOnClickListener { dismiss() } instructionsConfirm.setOnClickListener { diff --git a/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.java b/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.java deleted file mode 100644 index 8b6209342..000000000 --- a/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.java +++ /dev/null @@ -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 - */ - public Single 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 - */ - private Observable 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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.kt new file mode 100644 index 000000000..3095497c3 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/coordinates/CoordinateEditHelper.kt @@ -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 + */ + fun makeCoordinatesEdit( + context: Context, + media: Media, + latitude: String, + longitude: String, + accuracy: String + ): Single? { + 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 + */ + private fun addCoordinates( + media: Media, + Latitude: String, + Longitude: String, + Accuracy: String + ): Observable? { + 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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt new file mode 100644 index 000000000..39454c68d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt @@ -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, + 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): Boolean + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + + val contentResolver = context.contentResolver + val folderPath = folder.absolutePath + val urisToTrash = mutableListOf() + + 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 { + 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() + + 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): 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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index d39b69f29..6c6d7e53f 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -12,7 +12,11 @@ import android.view.View import android.view.Window import android.widget.Button import android.widget.ImageButton +import android.widget.PopupMenu 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.layout.Row 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.NotForUploadStatusDao 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.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding -import fr.free.nrw.commons.filepicker.Constants import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.upload.FileUtilsWrapper @@ -147,6 +150,23 @@ class CustomSelectorActivity : 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. */ @@ -225,23 +245,24 @@ class CustomSelectorActivity : /** * When data will be send from full screen mode, it will be passed to fragment */ - override fun onActivityResult( - requestCode: Int, - resultCode: Int, - data: Intent?, - ) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE && - resultCode == Activity.RESULT_OK - ) { + private fun onFullScreenDataReceived(result: ActivityResult){ + if (result.resultCode == Activity.RESULT_OK) { val selectedImages: ArrayList = - data!! + result.data!! .getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!! - val shouldRefresh = data.getBooleanExtra(SHOULD_REFRESH, false) - imageFragment?.passSelectedImages(selectedImages, shouldRefresh) + viewModel?.selectedImages?.value = selectedImages } } + private fun onDeleteFolderResultReceived(result: ActivityResult){ + if (result.resultCode == Activity.RESULT_OK){ + FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName) + navigateToCustomSelector() + } + } + + + /** * Show Custom Selector Welcome Dialog. */ @@ -423,10 +444,97 @@ class CustomSelectorActivity : val limitError: ImageButton = findViewById(R.id.image_limit_error) limitError.visibility = View.INVISIBLE 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( folderId: Long, @@ -444,6 +552,11 @@ class CustomSelectorActivity : bucketId = folderId bucketName = folderName isImageFragmentOpen = true + + //show the overflow menu only when a folder is clicked + showOverflowMenu = true + setUpToolbar() + } /** @@ -511,7 +624,7 @@ class CustomSelectorActivity : selectedImages, ) 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 changeTitle(getString(R.string.custom_selector_title), 0) } + + //hide overflow menu when not in folder + showOverflowMenu = false + setUpToolbar() } /** diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt index 7e522f681..3912a4d12 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt @@ -1,6 +1,7 @@ package fr.free.nrw.commons.customselector.ui.selector import android.app.Activity +import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences import android.os.Bundle @@ -279,11 +280,17 @@ class ImageFragment : filteredImages = ImageHelper.filterImages(images, bucketId) allImages = ArrayList(filteredImages) imageAdapter.init(filteredImages, allImages, TreeMap(), uploadingContributions) + viewModel?.selectedImages?.value?.let { selectedImages -> + imageAdapter.setSelectedImages(selectedImages) + } + imageAdapter.notifyDataSetChanged() selectorRV?.let { it.visibility = View.VISIBLE - lastItemId?.let { pos -> - (it.layoutManager as GridLayoutManager) - .scrollToPosition(ImageHelper.getIndexFromId(filteredImages, pos)) + if (switch?.isChecked == false) { + lastItemId?.let { pos -> + (it.layoutManager as GridLayoutManager) + .scrollToPosition(ImageHelper.getIndexFromId(filteredImages, pos)) + } } } } else { @@ -340,7 +347,7 @@ class ImageFragment : context .getSharedPreferences( "CustomSelector", - BaseActivity.MODE_PRIVATE, + MODE_PRIVATE, )?.let { prefs -> prefs.edit()?.let { editor -> editor.putLong("ItemId", imageAdapter.getImageIdAt(position))?.apply() @@ -382,14 +389,6 @@ class ImageFragment : selectedImages: ArrayList, shouldRefresh: Boolean, ) { - imageAdapter.setSelectedImages(selectedImages) - - val uploadingContributions = getUploadingContributions() - - if (!showAlreadyActionedImages && shouldRefresh) { - imageAdapter.init(filteredImages, allImages, TreeMap(), uploadingContributions) - imageAdapter.setSelectedImages(selectedImages) - } } /** diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt index 1fb5c5953..ddfcf341e 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt @@ -17,6 +17,7 @@ import fr.free.nrw.commons.utils.CustomSelectorUtils import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1 import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import java.util.Calendar diff --git a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java deleted file mode 100644 index 7ee417fbc..000000000 --- a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java +++ /dev/null @@ -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(); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt new file mode 100644 index 000000000..83f7687d4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt @@ -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() + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/db/Converters.java b/app/src/main/java/fr/free/nrw/commons/db/Converters.java deleted file mode 100644 index a70cdc815..000000000 --- a/app/src/main/java/fr/free/nrw/commons/db/Converters.java +++ /dev/null @@ -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() { - }); - } - - @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 objectList) { - return writeObjectToString(objectList); - } - - @TypeConverter - public static List stringToListObject(String objectList) { - return readObjectWithTypeToken(objectList, new TypeToken>() {}); - } - - @TypeConverter - public static String mapObjectToString(Map objectList) { - return writeObjectToString(objectList); - } - - @TypeConverter - public static String mapObjectToString2(Map objectList) { - return writeObjectToString(objectList); - } - - @TypeConverter - public static Map stringToMap(String objectList) { - return readObjectWithTypeToken(objectList, new TypeToken>(){}); - } - - @TypeConverter - public static Map stringToMap2(String objectList) { - return readObjectWithTypeToken(objectList, new TypeToken>(){}); - } - - @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 depictedItems) { - return writeObjectToString(depictedItems); - } - - @TypeConverter - public static List stringToList(String depictedItems) { - return readObjectWithTypeToken(depictedItems, new TypeToken>() {}); - } - - @TypeConverter - public static Sitelinks sitelinksFromString(String value) { - Type type = new TypeToken() {}.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 readObjectFromString(String objectAsString, Class clazz) { - return objectAsString == null ? null : getGson().fromJson(objectAsString, clazz); - } - - private static T readObjectWithTypeToken(String objectList, TypeToken typeToken) { - return objectList == null ? null : getGson().fromJson(objectList, typeToken.getType()); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/db/Converters.kt b/app/src/main/java/fr/free/nrw/commons/db/Converters.kt new file mode 100644 index 000000000..43b3a6184 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/db/Converters.kt @@ -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() {}) + } + + @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? { + return writeObjectToString(objectList) + } + + @TypeConverter + @JvmStatic + fun stringToListObject(objectList: String?): List? { + return readObjectWithTypeToken(objectList, object : TypeToken>() {}) + } + + @TypeConverter + @JvmStatic + fun mapObjectToString(objectList: Map?): String? { + return writeObjectToString(objectList) + } + + @TypeConverter + @JvmStatic + fun mapObjectToString2(objectList: Map?): String? { + return writeObjectToString(objectList) + } + + @TypeConverter + @JvmStatic + fun stringToMap(objectList: String?): Map? { + return readObjectWithTypeToken(objectList, object : TypeToken>() {}) + } + + @TypeConverter + @JvmStatic + fun stringToMap2(objectList: String?): Map? { + return readObjectWithTypeToken(objectList, object : TypeToken>() {}) + } + + @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?): String? { + return writeObjectToString(depictedItems) + } + + @TypeConverter + @JvmStatic + fun stringToList(depictedItems: String?): List? { + return readObjectWithTypeToken(depictedItems, object : TypeToken>() {}) + } + + @TypeConverter + @JvmStatic + fun sitelinksFromString(value: String?): Sitelinks? { + val type = object : TypeToken() {}.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 readObjectFromString(objectAsString: String?, clazz: Class): T? { + return objectAsString?.let { getGson().fromJson(it, clazz) } + } + + private fun readObjectWithTypeToken(objectList: String?, typeToken: TypeToken): T? { + return objectList?.let { getGson().fromJson(it, typeToken.type) } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java b/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java deleted file mode 100644 index 02f7d418e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java +++ /dev/null @@ -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 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 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 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>) () -> - 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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.kt b/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.kt new file mode 100644 index 000000000..be0b2bd79 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.kt @@ -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? { + + 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 { + 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() + + val reasonList: Array + val reasonListEnglish: Array + + 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 +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/delete/ReasonBuilder.java b/app/src/main/java/fr/free/nrw/commons/delete/ReasonBuilder.java deleted file mode 100644 index 35d682248..000000000 --- a/app/src/main/java/fr/free/nrw/commons/delete/ReasonBuilder.java +++ /dev/null @@ -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 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 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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/delete/ReasonBuilder.kt b/app/src/main/java/fr/free/nrw/commons/delete/ReasonBuilder.kt new file mode 100644 index 000000000..09018c249 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/delete/ReasonBuilder.kt @@ -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 { + 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 { + 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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt b/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt index 0dbdf71ae..32c1e5829 100644 --- a/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt @@ -6,6 +6,8 @@ import android.os.Bundle import android.os.Parcelable import android.speech.RecognizerIntent import android.view.View +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import fr.free.nrw.commons.CommonsApplication @@ -70,10 +72,14 @@ class DescriptionEditActivity : private lateinit var binding: ActivityDescriptionEditBinding - private val requestCodeForVoiceInput = 1213 - private var descriptionAndCaptions: ArrayList? = null + private val voiceInputResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> + onVoiceInput(result) + } + @Inject lateinit var descriptionEditHelper: DescriptionEditHelper @Inject lateinit var sessionManager: SessionManager @@ -115,6 +121,7 @@ class DescriptionEditActivity : savedLanguageValue, descriptionAndCaptions, recentLanguagesDao, + voiceInputResultLauncher ) uploadMediaDetailAdapter.setCallback { titleStringID: Int, messageStringId: Int -> showInfoAlert( @@ -142,13 +149,21 @@ class DescriptionEditActivity : getString(titleStringID), getString(messageStringId), getString(android.R.string.ok), - null, - true, + null ) } 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 */ @@ -221,7 +236,7 @@ class DescriptionEditActivity : ) { try { descriptionEditHelper - ?.addDescription( + .addDescription( applicationContext, media, updatedWikiText, @@ -234,7 +249,7 @@ class DescriptionEditActivity : ) } } catch (e: InvalidLoginTokenException) { - val username: String? = sessionManager?.userName + val username: String? = sessionManager.userName val logoutListener = CommonsApplication.BaseLogoutListener( this, @@ -242,7 +257,7 @@ class DescriptionEditActivity : username, ) - val commonsApplication = CommonsApplication.getInstance() + val commonsApplication = CommonsApplication.instance if (commonsApplication != null) { commonsApplication.clearApplicationData(this, logoutListener) } @@ -252,11 +267,11 @@ class DescriptionEditActivity : for (mediaDetail in uploadMediaDetails) { try { compositeDisposable.add( - descriptionEditHelper!! + descriptionEditHelper .addCaption( applicationContext, media, - mediaDetail.languageCode, + mediaDetail.languageCode!!, mediaDetail.captionText, ).subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) @@ -275,7 +290,7 @@ class DescriptionEditActivity : username, ) - val commonsApplication = CommonsApplication.getInstance() + val commonsApplication = CommonsApplication.instance if (commonsApplication != null) { commonsApplication.clearApplicationData(this, logoutListener) } @@ -288,26 +303,10 @@ class DescriptionEditActivity : progressDialog!!.isIndeterminate = true progressDialog!!.setTitle(getString(R.string.updating_caption_title)) progressDialog!!.setMessage(getString(R.string.updating_caption_message)) - progressDialog!!.setCanceledOnTouchOutside(false) + progressDialog!!.setCancelable(false) 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) { super.onSaveInstanceState(outState) diff --git a/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditHelper.java b/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditHelper.java deleted file mode 100644 index 05f6e9f2d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditHelper.java +++ /dev/null @@ -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 - */ - public Single 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 - */ - public Single 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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditHelper.kt new file mode 100644 index 000000000..5d948ddf9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditHelper.kt @@ -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 + */ + fun addDescription(context: Context, media: Media, appendText: String): Single { + 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 + */ + fun addCaption( + context: Context, + media: Media, + language: String, + value: String + ): Single { + 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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java deleted file mode 100644 index 4516d806f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java +++ /dev/null @@ -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(); -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.kt b/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.kt new file mode 100644 index 000000000..cf8e4808b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.kt @@ -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 +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.java b/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.java deleted file mode 100644 index f2bff5db7..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.java +++ /dev/null @@ -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 androidInjector; - @Inject DispatchingAndroidInjector activityInjector; - @Inject DispatchingAndroidInjector broadcastReceiverInjector; - @Inject DispatchingAndroidInjector fragmentInjector; - @Inject DispatchingAndroidInjector supportFragmentInjector; - @Inject DispatchingAndroidInjector serviceInjector; - @Inject DispatchingAndroidInjector contentProviderInjector; - - private CommonsApplicationComponent commonsApplicationComponent; - - public ApplicationlessInjection(Context applicationContext) { - commonsApplicationComponent = DaggerCommonsApplicationComponent.builder() - .appModule(new CommonsApplicationModule(applicationContext)).build(); - commonsApplicationComponent.inject(this); - } - - @Override - public AndroidInjector androidInjector() { - return androidInjector; - } - - @Override - public DispatchingAndroidInjector activityInjector() { - return activityInjector; - } - - @Override - public DispatchingAndroidInjector fragmentInjector() { - return fragmentInjector; - } - - @Override - public DispatchingAndroidInjector supportFragmentInjector() { - return supportFragmentInjector; - } - - @Override - public DispatchingAndroidInjector broadcastReceiverInjector() { - return broadcastReceiverInjector; - } - - @Override - public DispatchingAndroidInjector serviceInjector() { - return serviceInjector; - } - - @Override - public AndroidInjector 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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.kt b/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.kt new file mode 100644 index 000000000..1a88bd809 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/ApplicationlessInjection.kt @@ -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? = null + + @Inject @JvmField + var activityInjector: DispatchingAndroidInjector? = null + + @Inject @JvmField + var broadcastReceiverInjector: DispatchingAndroidInjector? = null + + @Inject @JvmField + var fragmentInjector: DispatchingAndroidInjector? = null + + @Inject @JvmField + var supportFragmentInjector: DispatchingAndroidInjector? = null + + @Inject @JvmField + var serviceInjector: DispatchingAndroidInjector? = null + + @Inject @JvmField + var contentProviderInjector: DispatchingAndroidInjector? = null + + val instance: ApplicationlessInjection get() = _instance!! + + val commonsApplicationComponent: CommonsApplicationComponent = + DaggerCommonsApplicationComponent + .builder() + .appModule(CommonsApplicationModule(applicationContext)) + .build() + + init { + commonsApplicationComponent.inject(this) + } + + override fun androidInjector(): AndroidInjector? = + androidInjector + + override fun activityInjector(): DispatchingAndroidInjector? = + activityInjector + + override fun fragmentInjector(): DispatchingAndroidInjector? = + fragmentInjector + + override fun supportFragmentInjector(): DispatchingAndroidInjector? = + supportFragmentInjector + + override fun broadcastReceiverInjector(): DispatchingAndroidInjector? = + broadcastReceiverInjector + + override fun serviceInjector(): DispatchingAndroidInjector? = + serviceInjector + + override fun contentProviderInjector(): AndroidInjector? = + 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!! + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java deleted file mode 100644 index 1390bd8ef..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java +++ /dev/null @@ -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 { - 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(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt new file mode 100644 index 000000000..b0c0c4d37 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java deleted file mode 100644 index cd7324c63..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java +++ /dev/null @@ -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 provideLicenses(Context context) { - List 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 provideLicensesByName(Context context) { - Map 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 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(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt new file mode 100644 index 000000000..b195674a9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt @@ -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 = 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 = 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 = + 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" + ) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerAppCompatActivity.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerAppCompatActivity.java deleted file mode 100644 index 003b3649c..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerAppCompatActivity.java +++ /dev/null @@ -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 supportFragmentInjector; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - inject(); - super.onCreate(savedInstanceState); - } - - @Override - public AndroidInjector 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 activityInjector = injection.activityInjector(); - - if (activityInjector == null) { - throw new NullPointerException("ApplicationlessInjection.activityInjector() returned null"); - } - - activityInjector.inject(this); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerAppCompatActivity.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerAppCompatActivity.kt new file mode 100644 index 000000000..fe9c7adee --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerAppCompatActivity.kt @@ -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? = null + + override fun onCreate(savedInstanceState: Bundle?) { + inject() + super.onCreate(savedInstanceState) + } + + override fun supportFragmentInjector(): AndroidInjector { + 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) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerBroadcastReceiver.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerBroadcastReceiver.java deleted file mode 100644 index 0b89003b5..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerBroadcastReceiver.java +++ /dev/null @@ -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 serviceInjector = injection.broadcastReceiverInjector(); - - if (serviceInjector == null) { - throw new NullPointerException("ApplicationlessInjection.broadcastReceiverInjector() returned null"); - } - serviceInjector.inject(this); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerBroadcastReceiver.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerBroadcastReceiver.kt new file mode 100644 index 000000000..4df25889f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerBroadcastReceiver.kt @@ -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) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerContentProvider.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerContentProvider.java deleted file mode 100644 index 06adee489..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerContentProvider.java +++ /dev/null @@ -1,32 +0,0 @@ -package fr.free.nrw.commons.di; - -import android.content.ContentProvider; - -import dagger.android.AndroidInjector; - - -public abstract class CommonsDaggerContentProvider extends ContentProvider { - - public CommonsDaggerContentProvider() { - super(); - } - - @Override - public boolean onCreate() { - inject(); - return true; - } - - private void inject() { - ApplicationlessInjection injection = ApplicationlessInjection.getInstance(getContext()); - - AndroidInjector serviceInjector = injection.contentProviderInjector(); - - if (serviceInjector == null) { - throw new NullPointerException("ApplicationlessInjection.contentProviderInjector() returned null"); - } - - serviceInjector.inject(this); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerContentProvider.kt new file mode 100644 index 000000000..c1bda689c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerContentProvider.kt @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.di + +import android.content.ContentProvider +import fr.free.nrw.commons.di.ApplicationlessInjection.Companion.getInstance + +abstract class CommonsDaggerContentProvider : ContentProvider() { + override fun onCreate(): Boolean { + inject() + return true + } + + private fun inject() { + val injection = getInstance(context!!) + + val serviceInjector = injection.contentProviderInjector() + ?: throw NullPointerException("ApplicationlessInjection.contentProviderInjector() returned null") + + serviceInjector.inject(this) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerIntentService.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerIntentService.java deleted file mode 100644 index 41f661db4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerIntentService.java +++ /dev/null @@ -1,32 +0,0 @@ -package fr.free.nrw.commons.di; - -import android.app.IntentService; -import android.app.Service; - -import dagger.android.AndroidInjector; - -public abstract class CommonsDaggerIntentService extends IntentService { - - public CommonsDaggerIntentService(String name) { - super(name); - } - - @Override - public void onCreate() { - inject(); - super.onCreate(); - } - - private void inject() { - ApplicationlessInjection injection = ApplicationlessInjection.getInstance(getApplicationContext()); - - AndroidInjector serviceInjector = injection.serviceInjector(); - - if (serviceInjector == null) { - throw new NullPointerException("ApplicationlessInjection.serviceInjector() returned null"); - } - - serviceInjector.inject(this); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerIntentService.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerIntentService.kt new file mode 100644 index 000000000..4aae35f0b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerIntentService.kt @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.di + +import android.app.IntentService +import fr.free.nrw.commons.di.ApplicationlessInjection.Companion.getInstance + +abstract class CommonsDaggerIntentService(name: String?) : IntentService(name) { + override fun onCreate() { + inject() + super.onCreate() + } + + private fun inject() { + val injection = getInstance(applicationContext) + + val serviceInjector = injection.serviceInjector() + ?: throw NullPointerException("ApplicationlessInjection.serviceInjector() returned null") + + serviceInjector.inject(this) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerService.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerService.java deleted file mode 100644 index 0d045d2ce..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerService.java +++ /dev/null @@ -1,31 +0,0 @@ -package fr.free.nrw.commons.di; - -import android.app.Service; - -import dagger.android.AndroidInjector; - -public abstract class CommonsDaggerService extends Service { - - public CommonsDaggerService() { - super(); - } - - @Override - public void onCreate() { - inject(); - super.onCreate(); - } - - private void inject() { - ApplicationlessInjection injection = ApplicationlessInjection.getInstance(getApplicationContext()); - - AndroidInjector serviceInjector = injection.serviceInjector(); - - if (serviceInjector == null) { - throw new NullPointerException("ApplicationlessInjection.serviceInjector() returned null"); - } - - serviceInjector.inject(this); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerService.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerService.kt new file mode 100644 index 000000000..3a67e0d2a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerService.kt @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.di + +import android.app.Service +import fr.free.nrw.commons.di.ApplicationlessInjection.Companion.getInstance + +abstract class CommonsDaggerService : Service() { + override fun onCreate() { + inject() + super.onCreate() + } + + private fun inject() { + val injection = getInstance(applicationContext) + + val serviceInjector = injection.serviceInjector() + ?: throw NullPointerException("ApplicationlessInjection.serviceInjector() returned null") + + serviceInjector.inject(this) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.java deleted file mode 100644 index f5ef2dd28..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.java +++ /dev/null @@ -1,75 +0,0 @@ -package fr.free.nrw.commons.di; - -import android.app.Activity; -import android.content.Context; - -import androidx.fragment.app.Fragment; - -import javax.inject.Inject; - -import dagger.android.AndroidInjector; -import dagger.android.DispatchingAndroidInjector; -import dagger.android.support.HasSupportFragmentInjector; -import io.reactivex.disposables.CompositeDisposable; - -public abstract class CommonsDaggerSupportFragment extends Fragment implements HasSupportFragmentInjector { - - @Inject - DispatchingAndroidInjector childFragmentInjector; - - protected CompositeDisposable compositeDisposable = new CompositeDisposable(); - - @Override - public void onAttach(Context context) { - inject(); - super.onAttach(context); - } - - @Override - public void onDestroy() { - super.onDestroy(); - compositeDisposable.clear(); - } - - @Override - public AndroidInjector supportFragmentInjector() { - return childFragmentInjector; - } - - - public void inject() { - HasSupportFragmentInjector hasSupportFragmentInjector = findHasFragmentInjector(); - - AndroidInjector fragmentInjector = hasSupportFragmentInjector.supportFragmentInjector(); - - if (fragmentInjector == null) { - throw new NullPointerException(String.format("%s.supportFragmentInjector() returned null", hasSupportFragmentInjector.getClass().getCanonicalName())); - } - - fragmentInjector.inject(this); - } - - private HasSupportFragmentInjector findHasFragmentInjector() { - Fragment parentFragment = this; - - while ((parentFragment = parentFragment.getParentFragment()) != null) { - if (parentFragment instanceof HasSupportFragmentInjector) { - return (HasSupportFragmentInjector) parentFragment; - } - } - - Activity activity = getActivity(); - - if (activity instanceof HasSupportFragmentInjector) { - return (HasSupportFragmentInjector) activity; - } - - ApplicationlessInjection injection = ApplicationlessInjection.getInstance(activity.getApplicationContext()); - if (injection != null) { - return injection; - } - - throw new IllegalArgumentException(String.format("No injector was found for %s", getClass().getCanonicalName())); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt new file mode 100644 index 000000000..8204d4415 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt @@ -0,0 +1,66 @@ +package fr.free.nrw.commons.di + +import android.app.Activity +import android.content.Context +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 io.reactivex.disposables.CompositeDisposable +import javax.inject.Inject + +abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInjector { + + @Inject @JvmField + var childFragmentInjector: DispatchingAndroidInjector? = null + + @JvmField + protected var compositeDisposable: CompositeDisposable = CompositeDisposable() + + override fun onAttach(context: Context) { + inject() + super.onAttach(context) + } + + override fun onDestroy() { + super.onDestroy() + compositeDisposable.clear() + } + + override fun supportFragmentInjector(): AndroidInjector = + childFragmentInjector!! + + + fun inject() { + val hasSupportFragmentInjector = findHasFragmentInjector() + + val fragmentInjector = hasSupportFragmentInjector.supportFragmentInjector() + ?: throw NullPointerException( + String.format( + "%s.supportFragmentInjector() returned null", + hasSupportFragmentInjector.javaClass.canonicalName + ) + ) + + fragmentInjector.inject(this) + } + + private fun findHasFragmentInjector(): HasSupportFragmentInjector { + var parentFragment: Fragment? = this + + while ((parentFragment!!.parentFragment.also { parentFragment = it }) != null) { + if (parentFragment is HasSupportFragmentInjector) { + return parentFragment as HasSupportFragmentInjector + } + } + + val activity: Activity = requireActivity() + + if (activity is HasSupportFragmentInjector) { + return activity + } + + return getInstance(activity.applicationContext) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java deleted file mode 100644 index aca6a2bf9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java +++ /dev/null @@ -1,38 +0,0 @@ -package fr.free.nrw.commons.di; - -import dagger.Module; -import dagger.android.ContributesAndroidInjector; -import fr.free.nrw.commons.bookmarks.items.BookmarkItemsContentProvider; -import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsContentProvider; -import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider; -import fr.free.nrw.commons.category.CategoryContentProvider; -import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider; -import fr.free.nrw.commons.recentlanguages.RecentLanguagesContentProvider; - -/** - * This Class Represents the Module for dependency injection (using dagger) - * so, if a developer needs to add a new ContentProvider to the commons app - * then that must be mentioned here to inject the dependencies - */ -@Module -@SuppressWarnings({ "WeakerAccess", "unused" }) -public abstract class ContentProviderBuilderModule { - - @ContributesAndroidInjector - abstract CategoryContentProvider bindCategoryContentProvider(); - - @ContributesAndroidInjector - abstract RecentSearchesContentProvider bindRecentSearchesContentProvider(); - - @ContributesAndroidInjector - abstract BookmarkPicturesContentProvider bindBookmarkContentProvider(); - - @ContributesAndroidInjector - abstract BookmarkLocationsContentProvider bindBookmarkLocationContentProvider(); - - @ContributesAndroidInjector - abstract BookmarkItemsContentProvider bindBookmarkItemContentProvider(); - - @ContributesAndroidInjector - abstract RecentLanguagesContentProvider bindRecentLanguagesContentProvider(); -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.kt b/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.kt new file mode 100644 index 000000000..1882f77a9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.kt @@ -0,0 +1,37 @@ +package fr.free.nrw.commons.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsContentProvider +import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsContentProvider +import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider +import fr.free.nrw.commons.category.CategoryContentProvider +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider +import fr.free.nrw.commons.recentlanguages.RecentLanguagesContentProvider + +/** + * This Class Represents the Module for dependency injection (using dagger) + * so, if a developer needs to add a new ContentProvider to the commons app + * then that must be mentioned here to inject the dependencies + */ +@Module +@Suppress("unused") +abstract class ContentProviderBuilderModule { + @ContributesAndroidInjector + abstract fun bindCategoryContentProvider(): CategoryContentProvider + + @ContributesAndroidInjector + abstract fun bindRecentSearchesContentProvider(): RecentSearchesContentProvider + + @ContributesAndroidInjector + abstract fun bindBookmarkContentProvider(): BookmarkPicturesContentProvider + + @ContributesAndroidInjector + abstract fun bindBookmarkLocationContentProvider(): BookmarkLocationsContentProvider + + @ContributesAndroidInjector + abstract fun bindBookmarkItemContentProvider(): BookmarkItemsContentProvider + + @ContributesAndroidInjector + abstract fun bindRecentLanguagesContentProvider(): RecentLanguagesContentProvider +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java deleted file mode 100644 index 698ca1500..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java +++ /dev/null @@ -1,166 +0,0 @@ -package fr.free.nrw.commons.di; - -import dagger.Module; -import dagger.android.ContributesAndroidInjector; -import fr.free.nrw.commons.bookmarks.BookmarkFragment; -import fr.free.nrw.commons.bookmarks.BookmarkListRootFragment; -import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment; -import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment; -import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment; -import fr.free.nrw.commons.contributions.ContributionsFragment; -import fr.free.nrw.commons.contributions.ContributionsListFragment; -import fr.free.nrw.commons.customselector.ui.selector.FolderFragment; -import fr.free.nrw.commons.customselector.ui.selector.ImageFragment; -import fr.free.nrw.commons.explore.ExploreFragment; -import fr.free.nrw.commons.explore.ExploreListRootFragment; -import fr.free.nrw.commons.explore.ExploreMapRootFragment; -import fr.free.nrw.commons.explore.map.ExploreMapFragment; -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.search.SearchCategoryFragment; -import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment; -import fr.free.nrw.commons.explore.depictions.child.ChildDepictionsFragment; -import fr.free.nrw.commons.explore.depictions.media.DepictedImagesFragment; -import fr.free.nrw.commons.explore.depictions.parent.ParentDepictionsFragment; -import fr.free.nrw.commons.explore.depictions.search.SearchDepictionsFragment; -import fr.free.nrw.commons.explore.media.SearchMediaFragment; -import fr.free.nrw.commons.explore.recentsearches.RecentSearchesFragment; -import fr.free.nrw.commons.media.MediaDetailFragment; -import fr.free.nrw.commons.media.MediaDetailPagerFragment; -import fr.free.nrw.commons.navtab.MoreBottomSheetFragment; -import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment; -import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment; -import fr.free.nrw.commons.profile.achievements.AchievementsFragment; -import fr.free.nrw.commons.profile.leaderboard.LeaderboardFragment; -import fr.free.nrw.commons.review.ReviewImageFragment; -import fr.free.nrw.commons.settings.SettingsFragment; -import fr.free.nrw.commons.upload.FailedUploadsFragment; -import fr.free.nrw.commons.upload.PendingUploadsFragment; -import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment; -import fr.free.nrw.commons.upload.depicts.DepictsFragment; -import fr.free.nrw.commons.upload.license.MediaLicenseFragment; -import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment; - -/** - * This Class Represents the Module for dependency injection (using dagger) - * so, if a developer needs to add a new Fragment to the commons app - * then that must be mentioned here to inject the dependencies - */ -@Module -@SuppressWarnings({"WeakerAccess", "unused"}) -public abstract class FragmentBuilderModule { - - @ContributesAndroidInjector - abstract ContributionsListFragment bindContributionsListFragment(); - - @ContributesAndroidInjector - abstract MediaDetailFragment bindMediaDetailFragment(); - - @ContributesAndroidInjector - abstract FolderFragment bindFolderFragment(); - - @ContributesAndroidInjector - abstract ImageFragment bindImageFragment(); - - @ContributesAndroidInjector - abstract MediaDetailPagerFragment bindMediaDetailPagerFragment(); - - @ContributesAndroidInjector - abstract SettingsFragment bindSettingsFragment(); - - @ContributesAndroidInjector - abstract DepictedImagesFragment bindDepictedImagesFragment(); - - @ContributesAndroidInjector - abstract SearchMediaFragment bindBrowseImagesListFragment(); - - @ContributesAndroidInjector - abstract SearchCategoryFragment bindSearchCategoryListFragment(); - - @ContributesAndroidInjector - abstract SearchDepictionsFragment bindSearchDepictionListFragment(); - - @ContributesAndroidInjector - abstract RecentSearchesFragment bindRecentSearchesFragment(); - - @ContributesAndroidInjector - abstract ContributionsFragment bindContributionsFragment(); - - @ContributesAndroidInjector(modules = NearbyParentFragmentModule.class) - abstract NearbyParentFragment bindNearbyParentFragment(); - - @ContributesAndroidInjector - abstract BookmarkPicturesFragment bindBookmarkPictureListFragment(); - - @ContributesAndroidInjector(modules = BookmarkLocationsFragmentModule.class) - abstract BookmarkLocationsFragment bindBookmarkLocationListFragment(); - - @ContributesAndroidInjector(modules = BookmarkItemsFragmentModule.class) - abstract BookmarkItemsFragment bindBookmarkItemListFragment(); - - @ContributesAndroidInjector - abstract ReviewImageFragment bindReviewOutOfContextFragment(); - - @ContributesAndroidInjector - abstract UploadMediaDetailFragment bindUploadMediaDetailFragment(); - - @ContributesAndroidInjector - abstract UploadCategoriesFragment bindUploadCategoriesFragment(); - - @ContributesAndroidInjector - abstract DepictsFragment bindDepictsFragment(); - - @ContributesAndroidInjector - abstract MediaLicenseFragment bindMediaLicenseFragment(); - - @ContributesAndroidInjector - abstract ParentDepictionsFragment bindParentDepictionsFragment(); - - @ContributesAndroidInjector - abstract ChildDepictionsFragment bindChildDepictionsFragment(); - - @ContributesAndroidInjector - abstract CategoriesMediaFragment bindCategoriesMediaFragment(); - - @ContributesAndroidInjector - abstract SubCategoriesFragment bindSubCategoriesFragment(); - - @ContributesAndroidInjector - abstract ParentCategoriesFragment bindParentCategoriesFragment(); - - @ContributesAndroidInjector - abstract ExploreFragment bindExploreFragmentFragment(); - - @ContributesAndroidInjector - abstract ExploreListRootFragment bindExploreFeaturedRootFragment(); - - @ContributesAndroidInjector(modules = ExploreMapFragmentModule.class) - abstract ExploreMapFragment bindExploreNearbyUploadsFragment(); - - @ContributesAndroidInjector - abstract ExploreMapRootFragment bindExploreNearbyUploadsRootFragment(); - - @ContributesAndroidInjector - abstract BookmarkListRootFragment bindBookmarkListRootFragment(); - - @ContributesAndroidInjector - abstract BookmarkFragment bindBookmarkFragmentFragment(); - - @ContributesAndroidInjector - abstract MoreBottomSheetFragment bindMoreBottomSheetFragment(); - - @ContributesAndroidInjector - abstract MoreBottomSheetLoggedOutFragment bindMoreBottomSheetLoggedOutFragment(); - - @ContributesAndroidInjector - abstract AchievementsFragment bindAchievementsFragment(); - - @ContributesAndroidInjector - abstract LeaderboardFragment bindLeaderboardFragment(); - - @ContributesAndroidInjector - abstract PendingUploadsFragment bindPendingUploadsFragment(); - - @ContributesAndroidInjector - abstract FailedUploadsFragment bindFailedUploadsFragment(); -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.kt b/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.kt new file mode 100644 index 000000000..bfdb90181 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.kt @@ -0,0 +1,165 @@ +package fr.free.nrw.commons.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import fr.free.nrw.commons.bookmarks.BookmarkFragment +import fr.free.nrw.commons.bookmarks.BookmarkListRootFragment +import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment +import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment +import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment +import fr.free.nrw.commons.contributions.ContributionsFragment +import fr.free.nrw.commons.contributions.ContributionsListFragment +import fr.free.nrw.commons.customselector.ui.selector.FolderFragment +import fr.free.nrw.commons.customselector.ui.selector.ImageFragment +import fr.free.nrw.commons.explore.ExploreFragment +import fr.free.nrw.commons.explore.ExploreListRootFragment +import fr.free.nrw.commons.explore.ExploreMapRootFragment +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.search.SearchCategoryFragment +import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment +import fr.free.nrw.commons.explore.depictions.child.ChildDepictionsFragment +import fr.free.nrw.commons.explore.depictions.media.DepictedImagesFragment +import fr.free.nrw.commons.explore.depictions.parent.ParentDepictionsFragment +import fr.free.nrw.commons.explore.depictions.search.SearchDepictionsFragment +import fr.free.nrw.commons.explore.map.ExploreMapFragment +import fr.free.nrw.commons.explore.media.SearchMediaFragment +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesFragment +import fr.free.nrw.commons.media.MediaDetailFragment +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment +import fr.free.nrw.commons.profile.achievements.AchievementsFragment +import fr.free.nrw.commons.profile.leaderboard.LeaderboardFragment +import fr.free.nrw.commons.review.ReviewImageFragment +import fr.free.nrw.commons.settings.SettingsFragment +import fr.free.nrw.commons.upload.FailedUploadsFragment +import fr.free.nrw.commons.upload.PendingUploadsFragment +import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment +import fr.free.nrw.commons.upload.depicts.DepictsFragment +import fr.free.nrw.commons.upload.license.MediaLicenseFragment +import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment + +/** + * This Class Represents the Module for dependency injection (using dagger) + * so, if a developer needs to add a new Fragment to the commons app + * then that must be mentioned here to inject the dependencies + */ +@Module +@Suppress("unused") +abstract class FragmentBuilderModule { + @ContributesAndroidInjector + abstract fun bindContributionsListFragment(): ContributionsListFragment + + @ContributesAndroidInjector + abstract fun bindMediaDetailFragment(): MediaDetailFragment + + @ContributesAndroidInjector + abstract fun bindFolderFragment(): FolderFragment + + @ContributesAndroidInjector + abstract fun bindImageFragment(): ImageFragment + + @ContributesAndroidInjector + abstract fun bindMediaDetailPagerFragment(): MediaDetailPagerFragment + + @ContributesAndroidInjector + abstract fun bindSettingsFragment(): SettingsFragment + + @ContributesAndroidInjector + abstract fun bindDepictedImagesFragment(): DepictedImagesFragment + + @ContributesAndroidInjector + abstract fun bindBrowseImagesListFragment(): SearchMediaFragment + + @ContributesAndroidInjector + abstract fun bindSearchCategoryListFragment(): SearchCategoryFragment + + @ContributesAndroidInjector + abstract fun bindSearchDepictionListFragment(): SearchDepictionsFragment + + @ContributesAndroidInjector + abstract fun bindRecentSearchesFragment(): RecentSearchesFragment + + @ContributesAndroidInjector + abstract fun bindContributionsFragment(): ContributionsFragment + + @ContributesAndroidInjector(modules = [NearbyParentFragmentModule::class]) + abstract fun bindNearbyParentFragment(): NearbyParentFragment + + @ContributesAndroidInjector + abstract fun bindBookmarkPictureListFragment(): BookmarkPicturesFragment + + @ContributesAndroidInjector(modules = [BookmarkLocationsFragmentModule::class]) + abstract fun bindBookmarkLocationListFragment(): BookmarkLocationsFragment + + @ContributesAndroidInjector(modules = [BookmarkItemsFragmentModule::class]) + abstract fun bindBookmarkItemListFragment(): BookmarkItemsFragment + + @ContributesAndroidInjector + abstract fun bindReviewOutOfContextFragment(): ReviewImageFragment + + @ContributesAndroidInjector + abstract fun bindUploadMediaDetailFragment(): UploadMediaDetailFragment + + @ContributesAndroidInjector + abstract fun bindUploadCategoriesFragment(): UploadCategoriesFragment + + @ContributesAndroidInjector + abstract fun bindDepictsFragment(): DepictsFragment + + @ContributesAndroidInjector + abstract fun bindMediaLicenseFragment(): MediaLicenseFragment + + @ContributesAndroidInjector + abstract fun bindParentDepictionsFragment(): ParentDepictionsFragment + + @ContributesAndroidInjector + abstract fun bindChildDepictionsFragment(): ChildDepictionsFragment + + @ContributesAndroidInjector + abstract fun bindCategoriesMediaFragment(): CategoriesMediaFragment + + @ContributesAndroidInjector + abstract fun bindSubCategoriesFragment(): SubCategoriesFragment + + @ContributesAndroidInjector + abstract fun bindParentCategoriesFragment(): ParentCategoriesFragment + + @ContributesAndroidInjector + abstract fun bindExploreFragmentFragment(): ExploreFragment + + @ContributesAndroidInjector + abstract fun bindExploreFeaturedRootFragment(): ExploreListRootFragment + + @ContributesAndroidInjector(modules = [ExploreMapFragmentModule::class]) + abstract fun bindExploreNearbyUploadsFragment(): ExploreMapFragment + + @ContributesAndroidInjector + abstract fun bindExploreNearbyUploadsRootFragment(): ExploreMapRootFragment + + @ContributesAndroidInjector + abstract fun bindBookmarkListRootFragment(): BookmarkListRootFragment + + @ContributesAndroidInjector + abstract fun bindBookmarkFragmentFragment(): BookmarkFragment + + @ContributesAndroidInjector + abstract fun bindMoreBottomSheetFragment(): MoreBottomSheetFragment + + @ContributesAndroidInjector + abstract fun bindMoreBottomSheetLoggedOutFragment(): MoreBottomSheetLoggedOutFragment + + @ContributesAndroidInjector + abstract fun bindAchievementsFragment(): AchievementsFragment + + @ContributesAndroidInjector + abstract fun bindLeaderboardFragment(): LeaderboardFragment + + @ContributesAndroidInjector + abstract fun bindPendingUploadsFragment(): PendingUploadsFragment + + @ContributesAndroidInjector + abstract fun bindFailedUploadsFragment(): FailedUploadsFragment +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.java b/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.java deleted file mode 100644 index 6aef8d323..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.java +++ /dev/null @@ -1,350 +0,0 @@ -package fr.free.nrw.commons.di; - -import android.content.Context; -import androidx.annotation.NonNull; -import com.google.gson.Gson; -import dagger.Module; -import dagger.Provides; -import fr.free.nrw.commons.BetaConstants; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.OkHttpConnectionFactory; -import fr.free.nrw.commons.actions.PageEditClient; -import fr.free.nrw.commons.actions.PageEditInterface; -import fr.free.nrw.commons.actions.ThanksInterface; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.auth.csrf.CsrfTokenClient; -import fr.free.nrw.commons.auth.csrf.CsrfTokenInterface; -import fr.free.nrw.commons.auth.csrf.LogoutClient; -import fr.free.nrw.commons.auth.login.LoginClient; -import fr.free.nrw.commons.auth.login.LoginInterface; -import fr.free.nrw.commons.category.CategoryInterface; -import fr.free.nrw.commons.explore.depictions.DepictsClient; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.media.MediaDetailInterface; -import fr.free.nrw.commons.media.MediaInterface; -import fr.free.nrw.commons.media.PageMediaInterface; -import fr.free.nrw.commons.media.WikidataMediaInterface; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import fr.free.nrw.commons.mwapi.UserInterface; -import fr.free.nrw.commons.notification.NotificationInterface; -import fr.free.nrw.commons.review.ReviewInterface; -import fr.free.nrw.commons.upload.UploadInterface; -import fr.free.nrw.commons.upload.WikiBaseInterface; -import fr.free.nrw.commons.upload.depicts.DepictsInterface; -import fr.free.nrw.commons.wikidata.CommonsServiceFactory; -import fr.free.nrw.commons.wikidata.WikidataInterface; -import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar; -import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage; -import java.io.File; -import java.util.Locale; -import java.util.concurrent.TimeUnit; -import javax.inject.Named; -import javax.inject.Singleton; -import okhttp3.Cache; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.logging.HttpLoggingInterceptor; -import okhttp3.logging.HttpLoggingInterceptor.Level; -import fr.free.nrw.commons.wikidata.model.WikiSite; -import fr.free.nrw.commons.wikidata.GsonUtil; -import timber.log.Timber; - -@Module -@SuppressWarnings({"WeakerAccess", "unused"}) -public class NetworkingModule { - private static final String WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql"; - private static final String TOOLS_FORGE_URL = "https://tools.wmflabs.org/commons-android-app/tool-commons-android-app"; - - public static final long OK_HTTP_CACHE_SIZE = 10 * 1024 * 1024; - - private static final String NAMED_WIKI_DATA_WIKI_SITE = "wikidata-wikisite"; - private static final String NAMED_WIKI_PEDIA_WIKI_SITE = "wikipedia-wikisite"; - - public static final String NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE = "language-wikipedia-wikisite"; - - public static final String NAMED_COMMONS_CSRF = "commons-csrf"; - public static final String NAMED_WIKI_CSRF = "wiki-csrf"; - - @Provides - @Singleton - public OkHttpClient provideOkHttpClient(Context context, - HttpLoggingInterceptor httpLoggingInterceptor) { - File dir = new File(context.getCacheDir(), "okHttpCache"); - return new OkHttpClient.Builder() - .connectTimeout(120, TimeUnit.SECONDS) - .writeTimeout(120, TimeUnit.SECONDS) - .addInterceptor(httpLoggingInterceptor) - .readTimeout(120, TimeUnit.SECONDS) - .cache(new Cache(dir, OK_HTTP_CACHE_SIZE)) - .build(); - } - - @Provides - @Singleton - public CommonsServiceFactory serviceFactory(CommonsCookieJar cookieJar) { - return new CommonsServiceFactory(OkHttpConnectionFactory.getClient(cookieJar)); - } - - @Provides - @Singleton - public HttpLoggingInterceptor provideHttpLoggingInterceptor() { - HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(message -> { - Timber.tag("OkHttp").v(message); - }); - httpLoggingInterceptor.setLevel(BuildConfig.DEBUG ? Level.BODY: Level.BASIC); - return httpLoggingInterceptor; - } - - @Provides - @Singleton - public OkHttpJsonApiClient provideOkHttpJsonApiClient(OkHttpClient okHttpClient, - DepictsClient depictsClient, - @Named("tools_forge") HttpUrl toolsForgeUrl, - @Named("default_preferences") JsonKvStore defaultKvStore, - Gson gson) { - return new OkHttpJsonApiClient(okHttpClient, - depictsClient, - toolsForgeUrl, - WIKIDATA_SPARQL_QUERY_URL, - BuildConfig.WIKIMEDIA_CAMPAIGNS_URL, - gson); - } - - @Provides - @Singleton - public CommonsCookieStorage provideCookieStorage( - @Named("default_preferences") JsonKvStore preferences) { - CommonsCookieStorage cookieStorage = new CommonsCookieStorage(preferences); - cookieStorage.load(); - return cookieStorage; - } - - @Provides - @Singleton - public CommonsCookieJar provideCookieJar(CommonsCookieStorage storage) { - return new CommonsCookieJar(storage); - } - - @Named(NAMED_COMMONS_CSRF) - @Provides - @Singleton - public CsrfTokenClient provideCommonsCsrfTokenClient(SessionManager sessionManager, - @Named("commons-csrf-interface") CsrfTokenInterface tokenInterface, LoginClient loginClient, LogoutClient logoutClient) { - return new CsrfTokenClient(sessionManager, tokenInterface, loginClient, logoutClient); - } - - /** - * Provides a singleton instance of CsrfTokenClient for Wikidata. - * - * @param sessionManager The session manager to manage user sessions. - * @param tokenInterface The interface for obtaining CSRF tokens. - * @param loginClient The client for handling login operations. - * @param logoutClient The client for handling logout operations. - * @return A singleton instance of CsrfTokenClient. - */ - @Named(NAMED_WIKI_CSRF) - @Provides - @Singleton - public CsrfTokenClient provideWikiCsrfTokenClient(SessionManager sessionManager, - @Named("wikidata-csrf-interface") CsrfTokenInterface tokenInterface, LoginClient loginClient, LogoutClient logoutClient) { - return new CsrfTokenClient(sessionManager, tokenInterface, loginClient, logoutClient); - } - - /** - * Provides a singleton instance of CsrfTokenInterface for Wikidata. - * - * @param serviceFactory The factory used to create service interfaces. - * @return A singleton instance of CsrfTokenInterface for Wikidata. - */ - @Named("wikidata-csrf-interface") - @Provides - @Singleton - public CsrfTokenInterface provideWikidataCsrfTokenInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.WIKIDATA_URL, CsrfTokenInterface.class); - } - - @Named("commons-csrf-interface") - @Provides - @Singleton - public CsrfTokenInterface provideCsrfTokenInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, CsrfTokenInterface.class); - } - - @Provides - @Singleton - public LoginInterface provideLoginInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, LoginInterface.class); - } - - @Provides - @Singleton - public LoginClient provideLoginClient(LoginInterface loginInterface) { - return new LoginClient(loginInterface); - } - - @Provides - @Named("wikimedia_api_host") - @NonNull - @SuppressWarnings("ConstantConditions") - public String provideMwApiUrl() { - return BuildConfig.WIKIMEDIA_API_HOST; - } - - @Provides - @Named("tools_forge") - @NonNull - @SuppressWarnings("ConstantConditions") - public HttpUrl provideToolsForgeUrl() { - return HttpUrl.parse(TOOLS_FORGE_URL); - } - - @Provides - @Singleton - @Named(NAMED_WIKI_DATA_WIKI_SITE) - public WikiSite provideWikidataWikiSite() { - return new WikiSite(BuildConfig.WIKIDATA_URL); - } - - - /** - * Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere. - * @return returns a singleton Gson instance - */ - @Provides - @Singleton - public Gson provideGson() { - return GsonUtil.getDefaultGson(); - } - - @Provides - @Singleton - public ReviewInterface provideReviewInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, ReviewInterface.class); - } - - @Provides - @Singleton - public DepictsInterface provideDepictsInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.WIKIDATA_URL, DepictsInterface.class); - } - - @Provides - @Singleton - public WikiBaseInterface provideWikiBaseInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, WikiBaseInterface.class); - } - - @Provides - @Singleton - public UploadInterface provideUploadInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, UploadInterface.class); - } - - @Named("commons-page-edit-service") - @Provides - @Singleton - public PageEditInterface providePageEditService(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, PageEditInterface.class); - } - - @Named("wikidata-page-edit-service") - @Provides - @Singleton - public PageEditInterface provideWikiDataPageEditService(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.WIKIDATA_URL, PageEditInterface.class); - } - - @Named("commons-page-edit") - @Provides - @Singleton - public PageEditClient provideCommonsPageEditClient(@Named(NAMED_COMMONS_CSRF) CsrfTokenClient csrfTokenClient, - @Named("commons-page-edit-service") PageEditInterface pageEditInterface) { - return new PageEditClient(csrfTokenClient, pageEditInterface); - } - - /** - * Provides a singleton instance of PageEditClient for Wikidata. - * - * @param csrfTokenClient The client used to manage CSRF tokens. - * @param pageEditInterface The interface for page edit operations. - * @return A singleton instance of PageEditClient for Wikidata. - */ - @Named("wikidata-page-edit") - @Provides - @Singleton - public PageEditClient provideWikidataPageEditClient(@Named(NAMED_WIKI_CSRF) CsrfTokenClient csrfTokenClient, - @Named("wikidata-page-edit-service") PageEditInterface pageEditInterface) { - return new PageEditClient(csrfTokenClient, pageEditInterface); - } - - @Provides - @Singleton - public MediaInterface provideMediaInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, MediaInterface.class); - } - - /** - * Add provider for WikidataMediaInterface - * It creates a retrofit service for the commons wiki site - * @param commonsWikiSite commonsWikiSite - * @return WikidataMediaInterface - */ - @Provides - @Singleton - public WikidataMediaInterface provideWikidataMediaInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BetaConstants.COMMONS_URL, WikidataMediaInterface.class); - } - - @Provides - @Singleton - public MediaDetailInterface providesMediaDetailInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, MediaDetailInterface.class); - } - - @Provides - @Singleton - public CategoryInterface provideCategoryInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, CategoryInterface.class); - } - - @Provides - @Singleton - public ThanksInterface provideThanksInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, ThanksInterface.class); - } - - @Provides - @Singleton - public NotificationInterface provideNotificationInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, NotificationInterface.class); - } - - @Provides - @Singleton - public UserInterface provideUserInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.COMMONS_URL, UserInterface.class); - } - - @Provides - @Singleton - public WikidataInterface provideWikidataInterface(CommonsServiceFactory serviceFactory) { - return serviceFactory.create(BuildConfig.WIKIDATA_URL, WikidataInterface.class); - } - - /** - * Add provider for PageMediaInterface - * It creates a retrofit service for the wiki site using device's current language - */ - @Provides - @Singleton - public PageMediaInterface providePageMediaInterface(@Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) WikiSite wikiSite, CommonsServiceFactory serviceFactory) { - return serviceFactory.create(wikiSite.url(), PageMediaInterface.class); - } - - @Provides - @Singleton - @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) - public WikiSite provideLanguageWikipediaSite() { - return WikiSite.forLanguageCode(Locale.getDefault().getLanguage()); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt b/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt new file mode 100644 index 000000000..0e9d83478 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt @@ -0,0 +1,313 @@ +package fr.free.nrw.commons.di + +import android.content.Context +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import fr.free.nrw.commons.BetaConstants +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.OkHttpConnectionFactory +import fr.free.nrw.commons.actions.PageEditClient +import fr.free.nrw.commons.actions.PageEditInterface +import fr.free.nrw.commons.actions.ThanksInterface +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.auth.csrf.CsrfTokenClient +import fr.free.nrw.commons.auth.csrf.CsrfTokenInterface +import fr.free.nrw.commons.auth.csrf.LogoutClient +import fr.free.nrw.commons.auth.login.LoginClient +import fr.free.nrw.commons.auth.login.LoginInterface +import fr.free.nrw.commons.category.CategoryInterface +import fr.free.nrw.commons.explore.depictions.DepictsClient +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.media.MediaDetailInterface +import fr.free.nrw.commons.media.MediaInterface +import fr.free.nrw.commons.media.PageMediaInterface +import fr.free.nrw.commons.media.WikidataMediaInterface +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.mwapi.UserInterface +import fr.free.nrw.commons.notification.NotificationInterface +import fr.free.nrw.commons.review.ReviewInterface +import fr.free.nrw.commons.upload.UploadInterface +import fr.free.nrw.commons.upload.WikiBaseInterface +import fr.free.nrw.commons.upload.depicts.DepictsInterface +import fr.free.nrw.commons.wikidata.CommonsServiceFactory +import fr.free.nrw.commons.wikidata.GsonUtil +import fr.free.nrw.commons.wikidata.WikidataInterface +import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar +import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage +import fr.free.nrw.commons.wikidata.model.WikiSite +import okhttp3.Cache +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.logging.HttpLoggingInterceptor.Level +import timber.log.Timber +import java.io.File +import java.util.concurrent.TimeUnit +import javax.inject.Named +import javax.inject.Singleton + +@Module +@Suppress("unused") +class NetworkingModule { + @Provides + @Singleton + fun provideOkHttpClient( + context: Context, + httpLoggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .addInterceptor(httpLoggingInterceptor) + .readTimeout(120, TimeUnit.SECONDS) + .cache(Cache(File(context.cacheDir, "okHttpCache"), OK_HTTP_CACHE_SIZE)) + .build() + + @Provides + @Singleton + fun serviceFactory(cookieJar: CommonsCookieJar): CommonsServiceFactory = + CommonsServiceFactory(OkHttpConnectionFactory.getClient(cookieJar)) + + @Provides + @Singleton + fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor = + HttpLoggingInterceptor { message: String? -> + Timber.tag("OkHttp").v(message) + }.apply { + level = if (BuildConfig.DEBUG) Level.BODY else Level.BASIC + } + + @Provides + @Singleton + fun provideOkHttpJsonApiClient( + okHttpClient: OkHttpClient, + depictsClient: DepictsClient, + @Named("tools_forge") toolsForgeUrl: HttpUrl, + gson: Gson + ): OkHttpJsonApiClient = OkHttpJsonApiClient( + okHttpClient, depictsClient, toolsForgeUrl, WIKIDATA_SPARQL_QUERY_URL, + BuildConfig.WIKIMEDIA_CAMPAIGNS_URL, gson + ) + + @Provides + @Singleton + fun provideCookieStorage( + @Named("default_preferences") preferences: JsonKvStore + ): CommonsCookieStorage = CommonsCookieStorage(preferences).also { + it.load() + } + + @Provides + @Singleton + fun provideCookieJar(storage: CommonsCookieStorage): CommonsCookieJar = + CommonsCookieJar(storage) + + @Named(NAMED_COMMONS_CSRF) + @Provides + @Singleton + fun provideCommonsCsrfTokenClient( + sessionManager: SessionManager, + @Named("commons-csrf-interface") tokenInterface: CsrfTokenInterface, + loginClient: LoginClient, + logoutClient: LogoutClient + ): CsrfTokenClient = CsrfTokenClient(sessionManager, tokenInterface, loginClient, logoutClient) + + /** + * Provides a singleton instance of CsrfTokenClient for Wikidata. + * + * @param sessionManager The session manager to manage user sessions. + * @param tokenInterface The interface for obtaining CSRF tokens. + * @param loginClient The client for handling login operations. + * @param logoutClient The client for handling logout operations. + * @return A singleton instance of CsrfTokenClient. + */ + @Named(NAMED_WIKI_CSRF) + @Provides + @Singleton + fun provideWikiCsrfTokenClient( + sessionManager: SessionManager, + @Named("wikidata-csrf-interface") tokenInterface: CsrfTokenInterface, + loginClient: LoginClient, + logoutClient: LogoutClient + ): CsrfTokenClient = CsrfTokenClient(sessionManager, tokenInterface, loginClient, logoutClient) + + /** + * Provides a singleton instance of CsrfTokenInterface for Wikidata. + * + * @param factory The factory used to create service interfaces. + * @return A singleton instance of CsrfTokenInterface for Wikidata. + */ + @Named("wikidata-csrf-interface") + @Provides + @Singleton + fun provideWikidataCsrfTokenInterface(factory: CommonsServiceFactory): CsrfTokenInterface = + factory.create(BuildConfig.WIKIDATA_URL) + + @Named("commons-csrf-interface") + @Provides + @Singleton + fun provideCsrfTokenInterface(factory: CommonsServiceFactory): CsrfTokenInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Provides + @Singleton + fun provideLoginInterface(factory: CommonsServiceFactory): LoginInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Provides + @Singleton + fun provideLoginClient(loginInterface: LoginInterface): LoginClient = + LoginClient(loginInterface) + + @Provides + @Named("tools_forge") + fun provideToolsForgeUrl(): HttpUrl = TOOLS_FORGE_URL.toHttpUrlOrNull()!! + + @Provides + @Singleton + @Named(NAMED_WIKI_DATA_WIKI_SITE) + fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL) + + /** + * Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere. + * @return returns a singleton Gson instance + */ + @Provides + @Singleton + fun provideGson(): Gson = GsonUtil.defaultGson + + @Provides + @Singleton + fun provideReviewInterface(factory: CommonsServiceFactory): ReviewInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Provides + @Singleton + fun provideDepictsInterface(factory: CommonsServiceFactory): DepictsInterface = + factory.create(BuildConfig.WIKIDATA_URL) + + @Provides + @Singleton + fun provideWikiBaseInterface(factory: CommonsServiceFactory): WikiBaseInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Provides + @Singleton + fun provideUploadInterface(factory: CommonsServiceFactory): UploadInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Named("commons-page-edit-service") + @Provides + @Singleton + fun providePageEditService(factory: CommonsServiceFactory): PageEditInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Named("wikidata-page-edit-service") + @Provides + @Singleton + fun provideWikiDataPageEditService(factory: CommonsServiceFactory): PageEditInterface = + factory.create(BuildConfig.WIKIDATA_URL) + + @Named("commons-page-edit") + @Provides + @Singleton + fun provideCommonsPageEditClient( + @Named(NAMED_COMMONS_CSRF) csrfTokenClient: CsrfTokenClient, + @Named("commons-page-edit-service") pageEditInterface: PageEditInterface + ): PageEditClient = PageEditClient(csrfTokenClient, pageEditInterface) + + /** + * Provides a singleton instance of PageEditClient for Wikidata. + * + * @param csrfTokenClient The client used to manage CSRF tokens. + * @param pageEditInterface The interface for page edit operations. + * @return A singleton instance of PageEditClient for Wikidata. + */ + @Named("wikidata-page-edit") + @Provides + @Singleton + fun provideWikidataPageEditClient( + @Named(NAMED_WIKI_CSRF) csrfTokenClient: CsrfTokenClient, + @Named("wikidata-page-edit-service") pageEditInterface: PageEditInterface + ): PageEditClient = PageEditClient(csrfTokenClient, pageEditInterface) + + @Provides + @Singleton + fun provideMediaInterface(factory: CommonsServiceFactory): MediaInterface = + factory.create(BuildConfig.COMMONS_URL) + + /** + * Add provider for WikidataMediaInterface + * It creates a retrofit service for the commons wiki site + * @param commonsWikiSite commonsWikiSite + * @return WikidataMediaInterface + */ + @Provides + @Singleton + fun provideWikidataMediaInterface(factory: CommonsServiceFactory): WikidataMediaInterface = + factory.create(BetaConstants.COMMONS_URL) + + @Provides + @Singleton + fun providesMediaDetailInterface(factory: CommonsServiceFactory): MediaDetailInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Provides + @Singleton + fun provideCategoryInterface(factory: CommonsServiceFactory): CategoryInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Provides + @Singleton + fun provideThanksInterface(factory: CommonsServiceFactory): ThanksInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Provides + @Singleton + fun provideNotificationInterface(factory: CommonsServiceFactory): NotificationInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Provides + @Singleton + fun provideUserInterface(factory: CommonsServiceFactory): UserInterface = + factory.create(BuildConfig.COMMONS_URL) + + @Provides + @Singleton + fun provideWikidataInterface(factory: CommonsServiceFactory): WikidataInterface = + factory.create(BuildConfig.WIKIDATA_URL) + + /** + * Add provider for PageMediaInterface + * It creates a retrofit service for the wiki site using device's current language + */ + @Provides + @Singleton + fun providePageMediaInterface( + @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) wikiSite: WikiSite, + factory: CommonsServiceFactory + ): PageMediaInterface = factory.create(wikiSite.url()) + + @Provides + @Singleton + @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) + fun provideLanguageWikipediaSite(): WikiSite = + WikiSite.forDefaultLocaleLanguageCode() + + companion object { + private const val WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql" + private const val TOOLS_FORGE_URL = + "https://tools.wmflabs.org/commons-android-app/tool-commons-android-app" + + const val OK_HTTP_CACHE_SIZE: Long = (10 * 1024 * 1024).toLong() + + private const val NAMED_WIKI_DATA_WIKI_SITE = "wikidata-wikisite" + private const val NAMED_WIKI_PEDIA_WIKI_SITE = "wikipedia-wikisite" + + const val NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE: String = "language-wikipedia-wikisite" + + const val NAMED_COMMONS_CSRF: String = "commons-csrf" + const val NAMED_WIKI_CSRF: String = "wiki-csrf" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.java deleted file mode 100644 index 1fb52c937..000000000 --- a/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.java +++ /dev/null @@ -1,19 +0,0 @@ -package fr.free.nrw.commons.di; - -import dagger.Module; -import dagger.android.ContributesAndroidInjector; -import fr.free.nrw.commons.auth.WikiAccountAuthenticatorService; - -/** - * This Class Represents the Module for dependency injection (using dagger) - * so, if a developer needs to add a new Service to the commons app - * then that must be mentioned here to inject the dependencies - */ -@Module -@SuppressWarnings({"WeakerAccess", "unused"}) -public abstract class ServiceBuilderModule { - - @ContributesAndroidInjector - abstract WikiAccountAuthenticatorService bindWikiAccountAuthenticatorService(); - -} diff --git a/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.kt b/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.kt new file mode 100644 index 000000000..45dbd5721 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/di/ServiceBuilderModule.kt @@ -0,0 +1,17 @@ +package fr.free.nrw.commons.di + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import fr.free.nrw.commons.auth.WikiAccountAuthenticatorService + +/** + * This Class Represents the Module for dependency injection (using dagger) + * so, if a developer needs to add a new Service to the commons app + * then that must be mentioned here to inject the dependencies + */ +@Module +@Suppress("unused") +abstract class ServiceBuilderModule { + @ContributesAndroidInjector + abstract fun bindWikiAccountAuthenticatorService(): WikiAccountAuthenticatorService +} diff --git a/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt b/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt index d585a99e9..3da98075e 100644 --- a/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/edit/EditActivity.kt @@ -6,9 +6,7 @@ import android.animation.ValueAnimator import android.content.Intent import android.graphics.BitmapFactory import android.graphics.Matrix -import android.media.ExifInterface import android.os.Bundle -import android.util.Log import android.view.animation.AccelerateDecelerateInterpolator import android.widget.ImageView import android.widget.Toast @@ -16,10 +14,12 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.rotationMatrix import androidx.core.graphics.scaleMatrix import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface import androidx.lifecycle.ViewModelProvider import fr.free.nrw.commons.databinding.ActivityEditBinding import timber.log.Timber import java.io.File +import kotlin.math.ceil /** * An activity class for editing and rotating images using LLJTran with EXIF attribute preservation. @@ -42,11 +42,12 @@ class EditActivity : AppCompatActivity() { supportActionBar?.title = "" val intent = intent imageUri = intent.getStringExtra("image") ?: "" - vm = ViewModelProvider(this).get(EditViewModel::class.java) + vm = ViewModelProvider(this)[EditViewModel::class.java] val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) } + val exifTags = arrayOf( - ExifInterface.TAG_APERTURE, + ExifInterface.TAG_F_NUMBER, ExifInterface.TAG_DATETIME, ExifInterface.TAG_EXPOSURE_TIME, ExifInterface.TAG_FLASH, @@ -62,13 +63,13 @@ class EditActivity : AppCompatActivity() { ExifInterface.TAG_GPS_TIMESTAMP, ExifInterface.TAG_IMAGE_LENGTH, ExifInterface.TAG_IMAGE_WIDTH, - ExifInterface.TAG_ISO, + ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY, ExifInterface.TAG_MAKE, ExifInterface.TAG_MODEL, ExifInterface.TAG_ORIENTATION, ExifInterface.TAG_WHITE_BALANCE, - ExifInterface.WHITEBALANCE_AUTO, - ExifInterface.WHITEBALANCE_MANUAL, + ExifInterface.WHITE_BALANCE_AUTO, + ExifInterface.WHITE_BALANCE_MANUAL, ) for (tag in exifTags) { val attribute = sourceExif?.getAttribute(tag.toString()) @@ -88,38 +89,36 @@ class EditActivity : AppCompatActivity() { private fun init() { binding.iv.adjustViewBounds = true binding.iv.scaleType = ImageView.ScaleType.MATRIX - binding.iv.post( - Runnable { - val options = BitmapFactory.Options() - options.inJustDecodeBounds = true - BitmapFactory.decodeFile(imageUri, options) + binding.iv.post { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeFile(imageUri, options) - val bitmapWidth = options.outWidth - val bitmapHeight = options.outHeight + val bitmapWidth = options.outWidth + val bitmapHeight = options.outHeight - // Check if the bitmap dimensions exceed a certain threshold - val maxBitmapSize = 2000 // Set your maximum size here - if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) { - val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize) - options.inSampleSize = scaleFactor - options.inJustDecodeBounds = false - val scaledBitmap = BitmapFactory.decodeFile(imageUri, options) - binding.iv.setImageBitmap(scaledBitmap) - // Update the ImageView with the scaled bitmap - val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat() - binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt() - binding.iv.imageMatrix = scaleMatrix(scale, scale) - } else { - options.inJustDecodeBounds = false - val bitmap = BitmapFactory.decodeFile(imageUri, options) - binding.iv.setImageBitmap(bitmap) + // Check if the bitmap dimensions exceed a certain threshold + val maxBitmapSize = 2000 // Set your maximum size here + if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) { + val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize) + options.inSampleSize = scaleFactor + options.inJustDecodeBounds = false + val scaledBitmap = BitmapFactory.decodeFile(imageUri, options) + binding.iv.setImageBitmap(scaledBitmap) + // Update the ImageView with the scaled bitmap + val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat() + binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt() + binding.iv.imageMatrix = scaleMatrix(scale, scale) + } else { + options.inJustDecodeBounds = false + val bitmap = BitmapFactory.decodeFile(imageUri, options) + binding.iv.setImageBitmap(bitmap) - val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat() - binding.iv.layoutParams.height = (scale * bitmapHeight).toInt() - binding.iv.imageMatrix = scaleMatrix(scale, scale) - } - }, - ) + val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat() + binding.iv.layoutParams.height = (scale * bitmapHeight).toInt() + binding.iv.imageMatrix = scaleMatrix(scale, scale) + } + } binding.rotateBtn.setOnClickListener { animateImageHeight() } @@ -143,15 +142,15 @@ class EditActivity : AppCompatActivity() { val drawableWidth: Float = binding.iv .getDrawable() - .getIntrinsicWidth() + .intrinsicWidth .toFloat() val drawableHeight: Float = binding.iv .getDrawable() - .getIntrinsicHeight() + .intrinsicHeight .toFloat() - val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat() - val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat() + val viewWidth: Float = binding.iv.measuredWidth.toFloat() + val viewHeight: Float = binding.iv.measuredHeight.toFloat() val rotation = imageRotation % 360 val newRotation = rotation + 90 @@ -162,16 +161,23 @@ class EditActivity : AppCompatActivity() { Timber.d("Rotation $rotation") Timber.d("new Rotation $newRotation") - if (rotation == 0 || rotation == 180) { - imageScale = viewWidth / drawableWidth - newImageScale = viewWidth / drawableHeight - newViewHeight = (drawableWidth * newImageScale).toInt() - } else if (rotation == 90 || rotation == 270) { - imageScale = viewWidth / drawableHeight - newImageScale = viewWidth / drawableWidth - newViewHeight = (drawableHeight * newImageScale).toInt() - } else { - throw UnsupportedOperationException("rotation can 0, 90, 180 or 270. \${rotation} is unsupported") + when (rotation) { + 0, 180 -> { + imageScale = viewWidth / drawableWidth + newImageScale = viewWidth / drawableHeight + newViewHeight = (drawableWidth * newImageScale).toInt() + } + 90, 270 -> { + imageScale = viewWidth / drawableHeight + newImageScale = viewWidth / drawableWidth + newViewHeight = (drawableHeight * newImageScale).toInt() + } + else -> { + throw + UnsupportedOperationException( + "rotation can 0, 90, 180 or 270. \${rotation} is unsupported" + ) + } } val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L) @@ -204,7 +210,7 @@ class EditActivity : AppCompatActivity() { (complementaryAnimVal * viewHeight + animVal * newViewHeight).toInt() val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation - binding.iv.getLayoutParams().height = animatedHeight + binding.iv.layoutParams.height = animatedHeight val matrix: Matrix = rotationMatrix( animatedRotation, @@ -218,8 +224,8 @@ class EditActivity : AppCompatActivity() { drawableHeight / 2, ) matrix.postTranslate( - -(drawableWidth - binding.iv.getMeasuredWidth()) / 2, - -(drawableHeight - binding.iv.getMeasuredHeight()) / 2, + -(drawableWidth - binding.iv.measuredWidth) / 2, + -(drawableHeight - binding.iv.measuredHeight) / 2, ) binding.iv.setImageMatrix(matrix) binding.iv.requestLayout() @@ -267,9 +273,9 @@ class EditActivity : AppCompatActivity() { */ private fun copyExifData(editedImageExif: ExifInterface?) { for (attr in sourceExifAttributeList) { - Log.d("Tag is ${attr.first}", "Value is ${attr.second}") + Timber.d("Value is ${attr.second}") editedImageExif!!.setAttribute(attr.first, attr.second) - Log.d("Tag is ${attr.first}", "Value is ${attr.second}") + Timber.d("Value is ${attr.second}") } editedImageExif?.saveAttributes() @@ -298,9 +304,10 @@ class EditActivity : AppCompatActivity() { var scaleFactor = 1 if (originalWidth > maxSize || originalHeight > maxSize) { - // Calculate the largest power of 2 that is less than or equal to the desired width and height - val widthRatio = Math.ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt() - val heightRatio = Math.ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt() + // Calculate the largest power of 2 that is less than or equal to the desired + // width and height + val widthRatio = ceil((originalWidth.toDouble() / maxSize.toDouble())).toInt() + val heightRatio = ceil((originalHeight.toDouble() / maxSize.toDouble())).toInt() scaleFactor = if (widthRatio > heightRatio) widthRatio else heightRatio } diff --git a/app/src/main/java/fr/free/nrw/commons/edit/TransformImageImpl.kt b/app/src/main/java/fr/free/nrw/commons/edit/TransformImageImpl.kt index b59619691..c3db1a5a0 100644 --- a/app/src/main/java/fr/free/nrw/commons/edit/TransformImageImpl.kt +++ b/app/src/main/java/fr/free/nrw/commons/edit/TransformImageImpl.kt @@ -65,7 +65,6 @@ class TransformImageImpl : TransformImage { } catch (e: LLJTranException) { Timber.tag("Error").d(e) return null - false } if (rotated) { diff --git a/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java index c66cd5163..d444148d4 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java @@ -11,7 +11,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.viewpager.widget.ViewPager.OnPageChangeListener; -import com.google.android.material.tabs.TabLayout; import fr.free.nrw.commons.R; import fr.free.nrw.commons.ViewPagerAdapter; import fr.free.nrw.commons.contributions.MainActivity; @@ -22,6 +21,7 @@ import fr.free.nrw.commons.theme.BaseActivity; import fr.free.nrw.commons.utils.ActivityUtils; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import javax.inject.Inject; import javax.inject.Named; @@ -112,13 +112,13 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { mobileRootFragment = new ExploreListRootFragment(mobileArguments); mapRootFragment = new ExploreMapRootFragment(mapArguments); fragmentList.add(featuredRootFragment); - titleList.add(getString(R.string.explore_tab_title_featured).toUpperCase()); + titleList.add(getString(R.string.explore_tab_title_featured).toUpperCase(Locale.ROOT)); fragmentList.add(mobileRootFragment); - titleList.add(getString(R.string.explore_tab_title_mobile).toUpperCase()); + titleList.add(getString(R.string.explore_tab_title_mobile).toUpperCase(Locale.ROOT)); fragmentList.add(mapRootFragment); - titleList.add(getString(R.string.explore_tab_title_map).toUpperCase()); + titleList.add(getString(R.string.explore_tab_title_map).toUpperCase(Locale.ROOT)); ((MainActivity)getActivity()).showTabs(); ((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false); diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java index 7717f2deb..934bff6ec 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java @@ -28,6 +28,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers; import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.concurrent.TimeUnit; import javax.inject.Inject; import timber.log.Timber; @@ -95,15 +96,15 @@ public class SearchActivity extends BaseActivity searchDepictionsFragment = new SearchDepictionsFragment(); searchCategoryFragment= new SearchCategoryFragment(); fragmentList.add(searchMediaFragment); - titleList.add(getResources().getString(R.string.search_tab_title_media).toUpperCase()); + titleList.add(getResources().getString(R.string.search_tab_title_media).toUpperCase(Locale.ROOT)); fragmentList.add(searchCategoryFragment); - titleList.add(getResources().getString(R.string.search_tab_title_categories).toUpperCase()); + titleList.add(getResources().getString(R.string.search_tab_title_categories).toUpperCase(Locale.ROOT)); fragmentList.add(searchDepictionsFragment); - titleList.add(getResources().getString(R.string.search_tab_title_depictions).toUpperCase()); + titleList.add(getResources().getString(R.string.search_tab_title_depictions).toUpperCase(Locale.ROOT)); viewPagerAdapter.setTabData(fragmentList, titleList); viewPagerAdapter.notifyDataSetChanged(); - compositeDisposable.add(RxSearchView.queryTextChanges(binding.searchBox) + getCompositeDisposable().add(RxSearchView.queryTextChanges(binding.searchBox) .takeUntil(RxView.detaches(binding.searchBox)) .debounce(500, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) @@ -283,7 +284,7 @@ public class SearchActivity extends BaseActivity @Override protected void onDestroy() { super.onDestroy(); //Dispose the disposables when the activity is destroyed - compositeDisposable.dispose(); + getCompositeDisposable().dispose(); binding = null; } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/media/CategoriesMediaFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/categories/media/CategoriesMediaFragment.kt index 6de1248b4..765abd698 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/categories/media/CategoriesMediaFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/media/CategoriesMediaFragment.kt @@ -18,6 +18,6 @@ class CategoriesMediaFragment : PageableMediaFragment() { savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}") + onQueryUpdated("$CATEGORY_PREFIX${requireArguments().getString("categoryName")!!}") } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/parent/ParentCategoriesFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/categories/parent/ParentCategoriesFragment.kt index 6ceccf607..c43e1c6bd 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/categories/parent/ParentCategoriesFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/parent/ParentCategoriesFragment.kt @@ -21,6 +21,6 @@ class ParentCategoriesFragment : PageableCategoryFragment() { savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}") + onQueryUpdated("$CATEGORY_PREFIX${requireArguments().getString("categoryName")!!}") } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/sub/SubCategoriesFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/categories/sub/SubCategoriesFragment.kt index 19fe52beb..8fbc83039 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/categories/sub/SubCategoriesFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/sub/SubCategoriesFragment.kt @@ -20,6 +20,6 @@ class SubCategoriesFragment : PageableCategoryFragment() { savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}") + onQueryUpdated("$CATEGORY_PREFIX${requireArguments().getString("categoryName")!!}") } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/child/ChildDepictionsFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/child/ChildDepictionsFragment.kt index 527536299..4f13b1be8 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/child/ChildDepictionsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/child/ChildDepictionsFragment.kt @@ -13,13 +13,13 @@ class ChildDepictionsFragment : PageableDepictionsFragment() { override val injectedPresenter get() = presenter - override fun getEmptyText(query: String) = getString(R.string.no_child_classes, arguments!!.getString("wikidataItemName")!!) + override fun getEmptyText(query: String) = getString(R.string.no_child_classes, requireArguments().getString("wikidataItemName")!!) override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - onQueryUpdated(arguments!!.getString("entityId")!!) + onQueryUpdated(requireArguments().getString("entityId")!!) } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/media/DepictedImagesFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/media/DepictedImagesFragment.kt index cc1b664b2..4cdb0e461 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/media/DepictedImagesFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/media/DepictedImagesFragment.kt @@ -17,6 +17,6 @@ class DepictedImagesFragment : PageableMediaFragment() { savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - onQueryUpdated(arguments!!.getString("entityId")!!) + onQueryUpdated(requireArguments().getString("entityId")!!) } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/parent/ParentDepictionsFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/parent/ParentDepictionsFragment.kt index 52a5aff5d..cf739a07d 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/parent/ParentDepictionsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/parent/ParentDepictionsFragment.kt @@ -13,13 +13,13 @@ class ParentDepictionsFragment : PageableDepictionsFragment() { override val injectedPresenter get() = presenter - override fun getEmptyText(query: String) = getString(R.string.no_parent_classes, arguments!!.getString("wikidataItemName")!!) + override fun getEmptyText(query: String) = getString(R.string.no_parent_classes, requireArguments().getString("wikidataItemName")!!) override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - onQueryUpdated(arguments!!.getString("entityId")!!) + onQueryUpdated(requireArguments().getString("entityId")!!) } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java index 52a5571e9..76627ebcf 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java @@ -4,14 +4,12 @@ import static fr.free.nrw.commons.location.LocationServiceManager.LocationChange import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.LOCATION_SLIGHTLY_CHANGED; import static fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL; -import android.Manifest; import android.Manifest.permission; import android.annotation.SuppressLint; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.Paint; @@ -21,22 +19,17 @@ import android.location.Location; import android.location.LocationManager; import android.os.Bundle; import android.preference.PreferenceManager; -import android.provider.Settings; import android.text.Html; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; -import android.widget.Toast; -import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatTextView; import androidx.core.content.ContextCompat; import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; import fr.free.nrw.commons.BaseMarker; import fr.free.nrw.commons.MapController; @@ -48,7 +41,6 @@ import fr.free.nrw.commons.databinding.FragmentExploreMapBinding; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.explore.ExploreMapRootFragment; import fr.free.nrw.commons.explore.paging.LiveDataConverter; -import fr.free.nrw.commons.filepicker.Constants; import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.location.LatLng; import fr.free.nrw.commons.location.LocationPermissionsHelper; @@ -60,7 +52,6 @@ import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.utils.DialogUtil; import fr.free.nrw.commons.utils.MapUtils; import fr.free.nrw.commons.utils.NetworkUtils; -import fr.free.nrw.commons.utils.PermissionUtils; import fr.free.nrw.commons.utils.SystemThemeUtils; import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.Observable; @@ -142,8 +133,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment askForLocationPermission(); }, null, - null, - false); + null + ); } else { if (isPermissionDenied) { locationPermissionsHelper.showAppSettingsDialog(getActivity(), @@ -310,7 +301,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment } private void startMapWithoutPermission() { - lastKnownLocation = MapUtils.defaultLatLng; + lastKnownLocation = MapUtils.getDefaultLatLng(); moveCameraToPosition( new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude())); presenter.onMapReady(exploreMapController); @@ -331,7 +322,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment !locationPermissionsHelper.checkLocationPermission(getActivity())) { isPermissionDenied = true; } - lastKnownLocation = MapUtils.defaultLatLng; + lastKnownLocation = MapUtils.getDefaultLatLng(); moveCameraToPosition( new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude())); presenter.onMapReady(exploreMapController); diff --git a/app/src/main/java/fr/free/nrw/commons/explore/media/MediaConverter.kt b/app/src/main/java/fr/free/nrw/commons/explore/media/MediaConverter.kt index a3103d41a..de084ba50 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/media/MediaConverter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/media/MediaConverter.kt @@ -11,7 +11,6 @@ import fr.free.nrw.commons.wikidata.model.Entities import fr.free.nrw.commons.wikidata.model.gallery.ExtMetadata import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage -import org.apache.commons.lang3.StringUtils import java.text.ParseException import java.util.Date import javax.inject.Inject @@ -24,7 +23,7 @@ class MediaConverter entity: Entities.Entity, imageInfo: ImageInfo, ): Media { - val metadata = imageInfo.metadata + val metadata = imageInfo.getMetadata() requireNotNull(metadata) { "No metadata" } // Stores mapping of title attribute to hidden attribute of each category val myMap = mutableMapOf() @@ -32,8 +31,8 @@ class MediaConverter return Media( page.pageId().toString(), - imageInfo.thumbUrl.takeIf { it.isNotBlank() } ?: imageInfo.originalUrl, - imageInfo.originalUrl, + imageInfo.getThumbUrl().takeIf { it.isNotBlank() } ?: imageInfo.getOriginalUrl(), + imageInfo.getOriginalUrl(), page.title(), metadata.imageDescription(), safeParseDate(metadata.dateTime()), @@ -41,7 +40,7 @@ class MediaConverter metadata.prefixedLicenseUrl, getAuthor(metadata), getAuthor(metadata), - MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories), + MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories()), metadata.latLng, entity.labels().mapValues { it.value.value() }, entity.descriptions().mapValues { it.value.value() }, @@ -104,9 +103,5 @@ private val ExtMetadata.prefixedLicenseUrl: String } private val ExtMetadata.latLng: LatLng? - get() = - if (!StringUtils.isBlank(gpsLatitude) && !StringUtils.isBlank(gpsLongitude)) { - LatLng(gpsLatitude.toDouble(), gpsLongitude.toDouble(), 0.0f) - } else { - null - } + get() = LatLng.latLongOrNull(gpsLatitude(), gpsLongitude()) + diff --git a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java index 9f12639dd..cee8a25ae 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.explore.recentsearches; +import android.annotation.SuppressLint; import android.content.ContentProviderClient; import android.content.ContentValues; import android.database.Cursor; @@ -178,6 +179,7 @@ public class RecentSearchesDao { * @return RecentSearch object */ @NonNull + @SuppressLint("Range") RecentSearch fromCursor(Cursor cursor) { // Hardcoding column positions! return new RecentSearch( diff --git a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java index cd98651f0..0db1e5539 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java @@ -15,6 +15,7 @@ import fr.free.nrw.commons.databinding.FragmentSearchHistoryBinding; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.explore.SearchActivity; import java.util.List; +import java.util.Locale; import javax.inject.Inject; @@ -90,7 +91,7 @@ public class RecentSearchesFragment extends CommonsDaggerSupportFragment { private void showDeleteAlertDialog(@NonNull final Context context, final int position) { new AlertDialog.Builder(context) .setMessage(R.string.delete_search_dialog) - .setPositiveButton(getString(R.string.delete).toUpperCase(), + .setPositiveButton(getString(R.string.delete).toUpperCase(Locale.ROOT), ((dialog, which) -> setDeletePositiveButton(context, dialog, position))) .setNegativeButton(android.R.string.cancel, null) .create() diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.java b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.java deleted file mode 100644 index 1723da723..000000000 --- a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.java +++ /dev/null @@ -1,120 +0,0 @@ -package fr.free.nrw.commons.feedback; - -import android.content.Context; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.AccountUtil; -import fr.free.nrw.commons.feedback.model.Feedback; -import fr.free.nrw.commons.utils.LangCodeUtils; -import java.util.Locale; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.TimeZone; - -/** - * Creates a wikimedia recognizable format - * from feedback information - */ -public class FeedbackContentCreator { - private StringBuilder sectionTextBuilder; - private StringBuilder sectionTitleBuilder; - private Feedback feedback; - private Context context; - - public FeedbackContentCreator(Context context, Feedback feedback) { - this.feedback = feedback; - this.context = context; - init(); - } - - /** - * Initializes the string buffer object to append content from feedback object - */ - public void init() { - // Localization is not needed here, because this ends up on a page where developers read the feedback, so English is the most convenient. - - /* - * Construct the feedback section title - */ - - //Get the UTC Date and Time and add it to the Title - final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.ENGLISH); - dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - final String UTC_FormattedDate = dateFormat.format(new Date()); - - sectionTitleBuilder = new StringBuilder(); - sectionTitleBuilder.append("Feedback from "); - sectionTitleBuilder.append(AccountUtil.getUserName(context)); - sectionTitleBuilder.append(" for version "); - sectionTitleBuilder.append(feedback.getVersion()); - sectionTitleBuilder.append(" on "); - sectionTitleBuilder.append(UTC_FormattedDate); - - /* - * Construct the feedback section text - */ - sectionTextBuilder = new StringBuilder(); - sectionTextBuilder.append("\n"); - sectionTextBuilder.append(feedback.getTitle()); - sectionTextBuilder.append("\n"); - sectionTextBuilder.append("\n"); - if (feedback.getApiLevel() != null) { - sectionTextBuilder.append("* "); - sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context, - Locale.ENGLISH).getString(R.string.api_level)); - sectionTextBuilder.append(": "); - sectionTextBuilder.append(feedback.getApiLevel()); - sectionTextBuilder.append("\n"); - } - if (feedback.getAndroidVersion() != null) { - sectionTextBuilder.append("* "); - sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context, - Locale.ENGLISH).getString(R.string.android_version)); - sectionTextBuilder.append(": "); - sectionTextBuilder.append(feedback.getAndroidVersion()); - sectionTextBuilder.append("\n"); - } - if (feedback.getDeviceManufacturer() != null) { - sectionTextBuilder.append("* "); - sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context, - Locale.ENGLISH).getString(R.string.device_manufacturer)); - sectionTextBuilder.append(": "); - sectionTextBuilder.append(feedback.getDeviceManufacturer()); - sectionTextBuilder.append("\n"); - } - if (feedback.getDeviceModel() != null) { - sectionTextBuilder.append("* "); - sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context, - Locale.ENGLISH).getString(R.string.device_model)); - sectionTextBuilder.append(": "); - sectionTextBuilder.append(feedback.getDeviceModel()); - sectionTextBuilder.append("\n"); - } - if (feedback.getDevice() != null) { - sectionTextBuilder.append("* "); - sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context, - Locale.ENGLISH).getString(R.string.device_name)); - sectionTextBuilder.append(": "); - sectionTextBuilder.append(feedback.getDevice()); - sectionTextBuilder.append("\n"); - } - if (feedback.getNetworkType() != null) { - sectionTextBuilder.append("* "); - sectionTextBuilder.append(LangCodeUtils.getLocalizedResources(context, - Locale.ENGLISH).getString(R.string.network_type)); - sectionTextBuilder.append(": "); - sectionTextBuilder.append(feedback.getNetworkType()); - sectionTextBuilder.append("\n"); - } - sectionTextBuilder.append("~~~~"); - sectionTextBuilder.append("\n"); - - } - - public String getSectionText() { - return sectionTextBuilder.toString(); - } - - public String getSectionTitle() { - return sectionTitleBuilder.toString(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.kt b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.kt new file mode 100644 index 000000000..2a11652ec --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackContentCreator.kt @@ -0,0 +1,123 @@ +package fr.free.nrw.commons.feedback + +import android.content.Context +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.getUserName +import fr.free.nrw.commons.feedback.model.Feedback +import fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class FeedbackContentCreator(context: Context, feedback: Feedback) { + private var sectionTitleBuilder = StringBuilder() + private var sectionTextBuilder = StringBuilder() + init { + // Localization is not needed here + // because this ends up on a page where developers read the feedback, + // so English is the most convenient. + + //Get the UTC Date and Time and add it to the Title + val dateFormat = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.ENGLISH) + dateFormat.timeZone = TimeZone.getTimeZone("UTC") + val utcFormattedDate = dateFormat.format(Date()) + + // Construct the feedback section title + sectionTitleBuilder.append("Feedback from ") + sectionTitleBuilder.append(getUserName(context)) + sectionTitleBuilder.append(" for version ") + sectionTitleBuilder.append(feedback.version) + sectionTitleBuilder.append(" on ") + sectionTitleBuilder.append(utcFormattedDate) + + // Construct the feedback section text + sectionTextBuilder = StringBuilder() + sectionTextBuilder.append("\n") + sectionTextBuilder.append(feedback.title) + sectionTextBuilder.append("\n") + sectionTextBuilder.append("\n") + if (feedback.apiLevel != null) { + sectionTextBuilder.append("* ") + sectionTextBuilder.append( + getLocalizedResources( + context, + Locale.ENGLISH + ).getString(R.string.api_level) + ) + sectionTextBuilder.append(": ") + sectionTextBuilder.append(feedback.apiLevel) + sectionTextBuilder.append("\n") + } + if (feedback.androidVersion != null) { + sectionTextBuilder.append("* ") + sectionTextBuilder.append( + getLocalizedResources( + context, + Locale.ENGLISH + ).getString(R.string.android_version) + ) + sectionTextBuilder.append(": ") + sectionTextBuilder.append(feedback.androidVersion) + sectionTextBuilder.append("\n") + } + if (feedback.deviceManufacturer != null) { + sectionTextBuilder.append("* ") + sectionTextBuilder.append( + getLocalizedResources( + context, + Locale.ENGLISH + ).getString(R.string.device_manufacturer) + ) + sectionTextBuilder.append(": ") + sectionTextBuilder.append(feedback.deviceManufacturer) + sectionTextBuilder.append("\n") + } + if (feedback.deviceModel != null) { + sectionTextBuilder.append("* ") + sectionTextBuilder.append( + getLocalizedResources( + context, + Locale.ENGLISH + ).getString(R.string.device_model) + ) + sectionTextBuilder.append(": ") + sectionTextBuilder.append(feedback.deviceModel) + sectionTextBuilder.append("\n") + } + if (feedback.device != null) { + sectionTextBuilder.append("* ") + sectionTextBuilder.append( + getLocalizedResources( + context, + Locale.ENGLISH + ).getString(R.string.device_name) + ) + sectionTextBuilder.append(": ") + sectionTextBuilder.append(feedback.device) + sectionTextBuilder.append("\n") + } + if (feedback.networkType != null) { + sectionTextBuilder.append("* ") + sectionTextBuilder.append( + getLocalizedResources( + context, + Locale.ENGLISH + ).getString(R.string.network_type) + ) + sectionTextBuilder.append(": ") + sectionTextBuilder.append(feedback.networkType) + sectionTextBuilder.append("\n") + } + sectionTextBuilder.append("~~~~") + sectionTextBuilder.append("\n") + } + + fun getSectionText(): String { + return sectionTextBuilder.toString() + } + + fun getSectionTitle(): String { + return sectionTitleBuilder.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.java b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.java deleted file mode 100644 index 2308623ec..000000000 --- a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.java +++ /dev/null @@ -1,75 +0,0 @@ -package fr.free.nrw.commons.feedback; - -import android.app.Dialog; -import android.content.Context; -import android.os.Bundle; -import android.text.Html; -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.view.View; -import android.view.WindowManager.LayoutParams; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.databinding.DialogFeedbackBinding; -import fr.free.nrw.commons.feedback.model.Feedback; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.DeviceInfoUtil; -import java.util.Objects; - -/** - * Feedback dialog that asks user for message and - * other device specifications - */ -public class FeedbackDialog extends Dialog { - DialogFeedbackBinding dialogFeedbackBinding; - - private OnFeedbackSubmitCallback onFeedbackSubmitCallback; - - private Spanned feedbackDestinationHtml; - - public FeedbackDialog(Context context, OnFeedbackSubmitCallback onFeedbackSubmitCallback) { - super(context); - this.onFeedbackSubmitCallback = onFeedbackSubmitCallback; - feedbackDestinationHtml = Html.fromHtml(context.getString(R.string.feedback_destination_note)); - } - - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - dialogFeedbackBinding = DialogFeedbackBinding.inflate(getLayoutInflater()); - dialogFeedbackBinding.feedbackDestination.setText(feedbackDestinationHtml); - dialogFeedbackBinding.feedbackDestination.setMovementMethod(LinkMovementMethod.getInstance()); - Objects.requireNonNull(getWindow()).setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE); - final View view = dialogFeedbackBinding.getRoot(); - setContentView(view); - dialogFeedbackBinding.btnSubmitFeedback.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - submitFeedback(); - } - }); - } - - /** - * When the button is clicked, it will create a feedback object - * and give a callback to calling activity/fragment - */ - void submitFeedback() { - if(dialogFeedbackBinding.feedbackItemEditText.getText().toString().equals("")) { - dialogFeedbackBinding.feedbackItemEditText.setError(getContext().getString(R.string.enter_description)); - return; - } - String appVersion = ConfigUtils.getVersionNameWithSha(getContext()); - String androidVersion = dialogFeedbackBinding.androidVersionCheckbox.isChecked() ? DeviceInfoUtil.getAndroidVersion() : null; - String apiLevel = dialogFeedbackBinding.apiLevelCheckbox.isChecked() ? DeviceInfoUtil.getAPILevel() : null; - String deviceManufacturer = dialogFeedbackBinding.deviceManufacturerCheckbox.isChecked() ? DeviceInfoUtil.getDeviceManufacturer() : null; - String deviceModel = dialogFeedbackBinding.deviceModelCheckbox.isChecked() ? DeviceInfoUtil.getDeviceModel() : null; - String deviceName = dialogFeedbackBinding.deviceNameCheckbox.isChecked() ? DeviceInfoUtil.getDevice() : null; - String networkType = dialogFeedbackBinding.networkTypeCheckbox.isChecked() ? DeviceInfoUtil.getConnectionType(getContext()).toString() : null; - Feedback feedback = new Feedback(appVersion, apiLevel - , dialogFeedbackBinding.feedbackItemEditText.getText().toString() - , androidVersion, deviceModel, deviceManufacturer, deviceName, networkType); - onFeedbackSubmitCallback.onFeedbackSubmit(feedback); - dismiss(); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt new file mode 100644 index 000000000..93cdab944 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt @@ -0,0 +1,107 @@ +package fr.free.nrw.commons.feedback + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.text.Html +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.view.WindowManager +import com.google.android.material.snackbar.Snackbar +import fr.free.nrw.commons.R +import fr.free.nrw.commons.databinding.DialogFeedbackBinding +import fr.free.nrw.commons.feedback.model.Feedback +import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha +import fr.free.nrw.commons.utils.DeviceInfoUtil.getAPILevel +import fr.free.nrw.commons.utils.DeviceInfoUtil.getAndroidVersion +import fr.free.nrw.commons.utils.DeviceInfoUtil.getConnectionType +import fr.free.nrw.commons.utils.DeviceInfoUtil.getDevice +import fr.free.nrw.commons.utils.DeviceInfoUtil.getDeviceManufacturer +import fr.free.nrw.commons.utils.DeviceInfoUtil.getDeviceModel +import java.net.ConnectException +import java.net.UnknownHostException + +class FeedbackDialog( + context: Context, + private val onFeedbackSubmitCallback: OnFeedbackSubmitCallback) : Dialog(context) { + private var _binding: DialogFeedbackBinding? = null + private val binding get() = _binding!! + // TODO("Remove Deprecation") Issue : #6002 + // 'fromHtml(String!): Spanned!' is deprecated. Deprecated in Java + @Suppress("DEPRECATION") + private var feedbackDestinationHtml: Spanned = Html.fromHtml( + context.getString(R.string.feedback_destination_note)) + + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + _binding = DialogFeedbackBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.feedbackDestination.text = feedbackDestinationHtml + binding.feedbackDestination.movementMethod = LinkMovementMethod.getInstance() + // TODO("DEPRECATION") Issue : #6002 + // 'SOFT_INPUT_ADJUST_RESIZE: Int' is deprecated. Deprecated in Java + @Suppress("DEPRECATION") + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + binding.btnSubmitFeedback.setOnClickListener { + try { + submitFeedback() + } catch (e: Exception) { + when (e) { + is UnknownHostException -> { + Snackbar.make(findViewById(android.R.id.content), + R.string.error_feedback, + Snackbar.LENGTH_SHORT).show() + } + + is ConnectException -> { + Snackbar.make(findViewById(android.R.id.content), + R.string.error_feedback, + Snackbar.LENGTH_SHORT).show() + } + + else -> { + Snackbar.make(findViewById(android.R.id.content), + R.string.error_feedback, + Snackbar.LENGTH_SHORT).show() + } + } + } + + } + } + + fun submitFeedback() { + if (binding.feedbackItemEditText.getText().toString() == "") { + binding.feedbackItemEditText.error = context.getString(R.string.enter_description) + return + } + val appVersion = context.getVersionNameWithSha() + val androidVersion = + if (binding.androidVersionCheckbox.isChecked) getAndroidVersion() else null + val apiLevel = + if (binding.apiLevelCheckbox.isChecked) getAPILevel() else null + val deviceManufacturer = + if (binding.deviceManufacturerCheckbox.isChecked) getDeviceManufacturer() else null + val deviceModel = + if (binding.deviceModelCheckbox.isChecked) getDeviceModel() else null + val deviceName = + if (binding.deviceNameCheckbox.isChecked) getDevice() else null + val networkType = + if (binding.networkTypeCheckbox.isChecked) getConnectionType( + context + ).toString() else null + val feedback = Feedback( + appVersion, apiLevel, + binding.feedbackItemEditText.getText().toString(), + androidVersion, deviceModel, deviceManufacturer, deviceName, networkType + ) + onFeedbackSubmitCallback.onFeedbackSubmit(feedback) + dismiss() + } + + override fun dismiss() { + super.dismiss() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.java b/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.java deleted file mode 100644 index 0d695061a..000000000 --- a/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.java +++ /dev/null @@ -1,15 +0,0 @@ -package fr.free.nrw.commons.feedback; - -import fr.free.nrw.commons.feedback.model.Feedback; - -/** - * This interface is used to provide callback - * from Feedback dialog whenever submit button is clicked - */ -public interface OnFeedbackSubmitCallback { - - /** - * callback function, called when user clicks on submit - */ - void onFeedbackSubmit(Feedback feedback); -} diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.kt b/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.kt new file mode 100644 index 000000000..5fd4f0490 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/feedback/OnFeedbackSubmitCallback.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.feedback + +import fr.free.nrw.commons.feedback.model.Feedback + +interface OnFeedbackSubmitCallback { + fun onFeedbackSubmit(feedback: Feedback) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.java b/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.java deleted file mode 100644 index 6e3a8cb0f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.java +++ /dev/null @@ -1,171 +0,0 @@ -package fr.free.nrw.commons.feedback.model; - -/** - * Pojo class for storing information that are required while uploading a feedback - */ -public class Feedback { - /** - * Version of app - */ - private String version; - /** - * API level of user's phone - */ - private String apiLevel; - /** - * Title/Description entered by user - */ - private String title; - /** - * Android version of user's device - */ - private String androidVersion; - /** - * Device Model of user's device - */ - private String deviceModel; - /** - * Device manufacturer name - */ - private String deviceManufacturer; - /** - * Device name stored on user's device - */ - private String device; - /** - * network type user is having (Ex: Wifi) - */ - private String networkType; - - public Feedback(final String version, final String apiLevel, final String title, final String androidVersion, - final String deviceModel, final String deviceManufacturer, final String device, final String networkType - ) { - this.version = version; - this.apiLevel = apiLevel; - this.title = title; - this.androidVersion = androidVersion; - this.deviceModel = deviceModel; - this.deviceManufacturer = deviceManufacturer; - this.device = device; - this.networkType = networkType; - } - - /** - * Get the version from which this piece of feedback is being sent. - * Ex: 3.0.1 - */ - public String getVersion() { - return version; - } - - /** - * Set the version of app to given version - */ - public void setVersion(final String version) { - this.version = version; - } - - /** - * gets api level of device - * Ex: 28 - */ - public String getApiLevel() { - return apiLevel; - } - - /** - * sets api level value to given value - */ - public void setApiLevel(final String apiLevel) { - this.apiLevel = apiLevel; - } - - /** - * gets feedback text entered by user - */ - public String getTitle() { - return title; - } - - /** - * sets feedback text - */ - public void setTitle(final String title) { - this.title = title; - } - - /** - * gets android version of device - * Ex: 9 - */ - public String getAndroidVersion() { - return androidVersion; - } - - /** - * sets value of android version - */ - public void setAndroidVersion(final String androidVersion) { - this.androidVersion = androidVersion; - } - - /** - * get device model of current device - * Ex: Redmi 6 Pro - */ - public String getDeviceModel() { - return deviceModel; - } - - /** - * sets value of device model to a given value - */ - public void setDeviceModel(final String deviceModel) { - this.deviceModel = deviceModel; - } - - /** - * get device manufacturer of user's device - * Ex: Redmi - */ - public String getDeviceManufacturer() { - return deviceManufacturer; - } - - /** - * set device manufacturer value to a given value - */ - public void setDeviceManufacturer(final String deviceManufacturer) { - this.deviceManufacturer = deviceManufacturer; - } - - /** - * get device name of user's device - */ - public String getDevice() { - return device; - } - - /** - * sets device name value to a given value - */ - public void setDevice(final String device) { - this.device = device; - } - - /** - * get network type of user's network - * Ex: wifi - */ - public String getNetworkType() { - return networkType; - } - - /** - * sets network type to a given value - */ - public void setNetworkType(final String networkType) { - this.networkType = networkType; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.kt b/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.kt new file mode 100644 index 000000000..f4af3425e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/feedback/model/Feedback.kt @@ -0,0 +1,30 @@ +package fr.free.nrw.commons.feedback.model + +/** + * Pojo class for storing information that are required while uploading a feedback + */ +data class Feedback ( + // Version of app + var version : String? = null, + + // API level of user's phone + var apiLevel: String? = null, + + // Title/Description entered by user + var title: String? = null, + + // Android version of user's device + var androidVersion: String? = null, + + // Device Model of user's device + var deviceModel: String? = null, + + // Device manufacturer name + var deviceManufacturer: String? = null, + + // Device name stored on user's device + var device: String? = null, + + // network type user is having (Ex: Wifi) + var networkType: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java b/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java deleted file mode 100644 index f907f0a01..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java +++ /dev/null @@ -1,32 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -public interface Constants { - String DEFAULT_FOLDER_NAME = "CommonsContributions"; - - /** - * Provides the request codes utilised by the FilePicker - */ - interface RequestCodes { - int LOCATION = 1; - int STORAGE = 2; - int FILE_PICKER_IMAGE_IDENTIFICATOR = 0b1101101100; //876 - int SOURCE_CHOOSER = 1 << 15; - - int PICK_PICTURE_FROM_CUSTOM_SELECTOR = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 10); - int PICK_PICTURE_FROM_DOCUMENTS = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 11); - int PICK_PICTURE_FROM_GALLERY = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 12); - int TAKE_PICTURE = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 13); - - int RECEIVE_DATA_FROM_FULL_SCREEN_MODE = 1 << 9; - } - - /** - * Provides locations as string for corresponding operations - */ - interface BundleKeys { - String FOLDER_NAME = "fr.free.nrw.commons.folder_name"; - String ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple"; - String COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos"; - String COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images"; - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt new file mode 100644 index 000000000..e405a6d52 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt @@ -0,0 +1,29 @@ +package fr.free.nrw.commons.filepicker + +interface Constants { + companion object { + const val DEFAULT_FOLDER_NAME = "CommonsContributions" + } + + /** + * Provides the request codes for permission handling + */ + interface RequestCodes { + companion object { + const val LOCATION = 1 + const val STORAGE = 2 + } + } + + /** + * Provides locations as string for corresponding operations + */ + interface BundleKeys { + companion object { + const val FOLDER_NAME = "fr.free.nrw.commons.folder_name" + const val ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple" + const val COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos" + const val COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images" + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java deleted file mode 100644 index e8373dc6f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java +++ /dev/null @@ -1,16 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -/** - * Provides abstract methods which are overridden while handling Contribution Results - * inside the ContributionsController - */ -public abstract class DefaultCallback implements FilePicker.Callbacks { - - @Override - public void onImagePickerError(Exception e, FilePicker.ImageSource source, int type) { - } - - @Override - public void onCanceled(FilePicker.ImageSource source, int type) { - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt new file mode 100644 index 000000000..baaba67b5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.filepicker + +/** + * Provides abstract methods which are overridden while handling Contribution Results + * inside the ContributionsController + */ +abstract class DefaultCallback: FilePicker.Callbacks { + + override fun onImagePickerError(e: Exception, source: FilePicker.ImageSource, type: Int) {} + + override fun onCanceled(source: FilePicker.ImageSource, type: Int) {} +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java deleted file mode 100644 index af3dc8622..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import androidx.core.content.FileProvider; - -public class ExtendedFileProvider extends FileProvider { - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt new file mode 100644 index 000000000..746058fc4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.filepicker + +import androidx.core.content.FileProvider + +class ExtendedFileProvider: FileProvider() {} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java deleted file mode 100644 index daa29276a..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java +++ /dev/null @@ -1,409 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import static fr.free.nrw.commons.filepicker.PickedFiles.singleFileList; - -import android.app.Activity; -import android.content.ClipData; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.provider.MediaStore; -import android.text.TextUtils; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.PreferenceManager; -import fr.free.nrw.commons.customselector.model.Image; -import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity; -import java.io.File; -import java.io.IOException; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; - -public class FilePicker implements Constants { - - private static final String KEY_PHOTO_URI = "photo_uri"; - private static final String KEY_VIDEO_URI = "video_uri"; - private static final String KEY_LAST_CAMERA_PHOTO = "last_photo"; - private static final String KEY_LAST_CAMERA_VIDEO = "last_video"; - private static final String KEY_TYPE = "type"; - - /** - * Returns the uri of the clicked image so that it can be put in MediaStore - */ - private static Uri createCameraPictureFile(@NonNull Context context) throws IOException { - File imagePath = PickedFiles.getCameraPicturesLocation(context); - Uri uri = PickedFiles.getUriToFile(context, imagePath); - SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit(); - editor.putString(KEY_PHOTO_URI, uri.toString()); - editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString()); - editor.apply(); - return uri; - } - - private static Intent createGalleryIntent(@NonNull Context context, int type, - boolean openDocumentIntentPreferred) { - // storing picked image type to shared preferences - storeType(context, type); - //Supported types are SVG, PNG and JPEG,GIF, TIFF, WebP, XCF - final String[] mimeTypes = { "image/jpg","image/png","image/jpeg", "image/gif", "image/tiff", "image/webp", "image/xcf", "image/svg+xml", "image/webp"}; - return plainGalleryPickerIntent(openDocumentIntentPreferred) - .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, configuration(context).allowsMultiplePickingInGallery()) - .putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); - } - - /** - * CreateCustomSectorIntent, creates intent for custom selector activity. - * @param context - * @param type - * @return Custom selector intent - */ - private static Intent createCustomSelectorIntent(@NonNull Context context, int type) { - storeType(context, type); - return new Intent(context, CustomSelectorActivity.class); - } - - private static Intent createCameraForImageIntent(@NonNull Context context, int type) { - storeType(context, type); - - Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - try { - Uri capturedImageUri = createCameraPictureFile(context); - //We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20 - grantWritePermission(context, intent, capturedImageUri); - intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri); - } catch (Exception e) { - e.printStackTrace(); - } - - return intent; - } - - private static void revokeWritePermission(@NonNull Context context, Uri uri) { - context.revokeUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - - private static void grantWritePermission(@NonNull Context context, Intent intent, Uri uri) { - List resInfoList = context.getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - private static void storeType(@NonNull Context context, int type) { - PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply(); - } - - private static int restoreType(@NonNull Context context) { - return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0); - } - - /** - * Opens default galery or a available galleries picker if there is no default - * - * @param type Custom type of your choice, which will be returned with the images - */ - public static void openGallery(Activity activity, int type, boolean openDocumentIntentPreferred) { - Intent intent = createGalleryIntent(activity, type, openDocumentIntentPreferred); - int requestCode = RequestCodes.PICK_PICTURE_FROM_GALLERY; - - if(openDocumentIntentPreferred){ - requestCode = RequestCodes.PICK_PICTURE_FROM_DOCUMENTS; - } - - activity.startActivityForResult(intent, requestCode); - } - - /** - * Opens Custom Selector - */ - public static void openCustomSelector(Activity activity, int type) { - Intent intent = createCustomSelectorIntent(activity, type); - activity.startActivityForResult(intent, RequestCodes.PICK_PICTURE_FROM_CUSTOM_SELECTOR); - } - - /** - * Opens the camera app to pick image clicked by user - */ - public static void openCameraForImage(Activity activity, int type) { - Intent intent = createCameraForImageIntent(activity, type); - activity.startActivityForResult(intent, RequestCodes.TAKE_PICTURE); - } - - @Nullable - private static UploadableFile takenCameraPicture(Context context) throws URISyntaxException { - String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_PHOTO, null); - if (lastCameraPhoto != null) { - return new UploadableFile(new File(lastCameraPhoto)); - } else { - return null; - } - } - - @Nullable - private static UploadableFile takenCameraVideo(Context context) throws URISyntaxException { - String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_VIDEO, null); - if (lastCameraPhoto != null) { - return new UploadableFile(new File(lastCameraPhoto)); - } else { - return null; - } - } - - /** - * Any activity can use this method to attach their callback to the file picker - */ - public static void handleActivityResult(int requestCode, int resultCode, Intent data, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - boolean isHandledPickedFile = (requestCode & RequestCodes.FILE_PICKER_IMAGE_IDENTIFICATOR) > 0; - if (isHandledPickedFile) { - requestCode &= ~RequestCodes.SOURCE_CHOOSER; - if (requestCode == RequestCodes.PICK_PICTURE_FROM_GALLERY || - requestCode == RequestCodes.TAKE_PICTURE || - requestCode == RequestCodes.PICK_PICTURE_FROM_DOCUMENTS || - requestCode == RequestCodes.PICK_PICTURE_FROM_CUSTOM_SELECTOR) { - if (resultCode == Activity.RESULT_OK) { - if (requestCode == RequestCodes.PICK_PICTURE_FROM_DOCUMENTS && !isPhoto(data)) { - onPictureReturnedFromDocuments(data, activity, callbacks); - } else if (requestCode == RequestCodes.PICK_PICTURE_FROM_GALLERY && !isPhoto(data)) { - onPictureReturnedFromGallery(data, activity, callbacks); - } else if (requestCode == RequestCodes.PICK_PICTURE_FROM_CUSTOM_SELECTOR) { - onPictureReturnedFromCustomSelector(data, activity, callbacks); - } else if (requestCode == RequestCodes.TAKE_PICTURE) { - onPictureReturnedFromCamera(activity, callbacks); - } - } else { - if (requestCode == RequestCodes.PICK_PICTURE_FROM_DOCUMENTS) { - callbacks.onCanceled(FilePicker.ImageSource.DOCUMENTS, restoreType(activity)); - } else if (requestCode == RequestCodes.PICK_PICTURE_FROM_GALLERY) { - callbacks.onCanceled(FilePicker.ImageSource.GALLERY, restoreType(activity)); - } else if (requestCode == RequestCodes.PICK_PICTURE_FROM_CUSTOM_SELECTOR){ - callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity)); - } - else { - callbacks.onCanceled(FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } - } - } - } - } - - public static List handleExternalImagesPicked(Intent data, Activity activity) { - try { - return getFilesFromGalleryPictures(data, activity); - } catch (IOException | SecurityException e) { - e.printStackTrace(); - } - return new ArrayList<>(); - } - - private static boolean isPhoto(Intent data) { - return data == null || (data.getData() == null && data.getClipData() == null); - } - - private static Intent plainGalleryPickerIntent(boolean openDocumentIntentPreferred) { - /* - * Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue - * in the custom selector in Contributions fragment. - * Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015 - * - * This permission check, however, was insufficient to fix location-loss in - * the regular selector in Contributions fragment and Nearby fragment, - * especially on some devices running Android 13 that use the new Photo Picker by default. - * - * New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker - * - * The new Photo Picker introduced by Android redacts location tags from EXIF metadata. - * Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058 - * Status: Won't fix (Intended behaviour) - * - * Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can - * be changed through the Setting page) as: - * - * ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data - * The best application is the new Photo Picker that redacts the location tags - * - * ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances - * installed on the device, letting the user interactively navigate through them. - * - * So, this allows us to use the traditional file picker that does not redact location tags - * from EXIF. - * - */ - Intent intent; - if (openDocumentIntentPreferred) { - intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - } else { - intent = new Intent(Intent.ACTION_GET_CONTENT); - } - intent.setType("image/*"); - return intent; - } - - private static void onPictureReturnedFromDocuments(Intent data, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - try { - Uri photoPath = data.getData(); - UploadableFile photoFile = PickedFiles.pickedExistingPicture(activity, photoPath); - callbacks.onImagesPicked(singleFileList(photoFile), FilePicker.ImageSource.DOCUMENTS, restoreType(activity)); - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)); - } - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.DOCUMENTS, restoreType(activity)); - } - } - - /** - * onPictureReturnedFromCustomSelector. - * Retrieve and forward the images to upload wizard through callback. - */ - private static void onPictureReturnedFromCustomSelector(Intent data, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - try { - List files = getFilesFromCustomSelector(data, activity); - callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity)); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity)); - } - } - - /** - * Get files from custom selector - * Retrieve and process the selected images from the custom selector. - */ - private static List getFilesFromCustomSelector(Intent data, Activity activity) throws IOException, SecurityException { - List files = new ArrayList<>(); - ArrayList images = data.getParcelableArrayListExtra("Images"); - for(Image image : images) { - Uri uri = image.getUri(); - UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri); - files.add(file); - } - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, files); - } - - return files; - } - - private static void onPictureReturnedFromGallery(Intent data, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - try { - List files = getFilesFromGalleryPictures(data, activity); - callbacks.onImagesPicked(files, FilePicker.ImageSource.GALLERY, restoreType(activity)); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.GALLERY, restoreType(activity)); - } - } - - private static List getFilesFromGalleryPictures(Intent data, Activity activity) throws IOException, SecurityException { - List files = new ArrayList<>(); - ClipData clipData = data.getClipData(); - if (clipData == null) { - Uri uri = data.getData(); - UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri); - files.add(file); - } else { - for (int i = 0; i < clipData.getItemCount(); i++) { - Uri uri = clipData.getItemAt(i).getUri(); - UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri); - files.add(file); - } - } - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, files); - } - - return files; - } - - private static void onPictureReturnedFromCamera(Activity activity, @NonNull FilePicker.Callbacks callbacks) { - try { - String lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity).getString(KEY_PHOTO_URI, null); - if (!TextUtils.isEmpty(lastImageUri)) { - revokeWritePermission(activity, Uri.parse(lastImageUri)); - } - - UploadableFile photoFile = FilePicker.takenCameraPicture(activity); - List files = new ArrayList<>(); - files.add(photoFile); - - if (photoFile == null) { - Exception e = new IllegalStateException("Unable to get the picture returned from camera"); - callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } else { - if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)); - } - - callbacks.onImagesPicked(files, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } - - PreferenceManager.getDefaultSharedPreferences(activity) - .edit() - .remove(KEY_LAST_CAMERA_PHOTO) - .remove(KEY_PHOTO_URI) - .apply(); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } - } - - private static void onVideoReturnedFromCamera(Activity activity, @NonNull FilePicker.Callbacks callbacks) { - try { - String lastVideoUri = PreferenceManager.getDefaultSharedPreferences(activity).getString(KEY_VIDEO_URI, null); - if (!TextUtils.isEmpty(lastVideoUri)) { - revokeWritePermission(activity, Uri.parse(lastVideoUri)); - } - - UploadableFile photoFile = FilePicker.takenCameraVideo(activity); - List files = new ArrayList<>(); - files.add(photoFile); - - if (photoFile == null) { - Exception e = new IllegalStateException("Unable to get the video returned from camera"); - callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_VIDEO, restoreType(activity)); - } else { - if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)); - } - - callbacks.onImagesPicked(files, FilePicker.ImageSource.CAMERA_VIDEO, restoreType(activity)); - } - - PreferenceManager.getDefaultSharedPreferences(activity) - .edit() - .remove(KEY_LAST_CAMERA_VIDEO) - .remove(KEY_VIDEO_URI) - .apply(); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_VIDEO, restoreType(activity)); - } - } - - public static FilePickerConfiguration configuration(@NonNull Context context) { - return new FilePickerConfiguration(context); - } - - - public enum ImageSource { - GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR - } - - public interface Callbacks { - void onImagePickerError(Exception e, FilePicker.ImageSource source, int type); - - void onImagesPicked(@NonNull List imageFiles, FilePicker.ImageSource source, int type); - - void onCanceled(FilePicker.ImageSource source, int type); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt new file mode 100644 index 000000000..6bf8a1061 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt @@ -0,0 +1,441 @@ +package fr.free.nrw.commons.filepicker + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.preference.PreferenceManager +import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity +import fr.free.nrw.commons.filepicker.PickedFiles.singleFileList +import java.io.File +import java.io.IOException +import java.net.URISyntaxException + + +object FilePicker : Constants { + + private const val KEY_PHOTO_URI = "photo_uri" + private const val KEY_VIDEO_URI = "video_uri" + private const val KEY_LAST_CAMERA_PHOTO = "last_photo" + private const val KEY_LAST_CAMERA_VIDEO = "last_video" + private const val KEY_TYPE = "type" + + /** + * Returns the uri of the clicked image so that it can be put in MediaStore + */ + @Throws(IOException::class) + @JvmStatic + private fun createCameraPictureFile(context: Context): Uri { + val imagePath = PickedFiles.getCameraPicturesLocation(context) + val uri = PickedFiles.getUriToFile(context, imagePath) + val editor = PreferenceManager.getDefaultSharedPreferences(context).edit() + editor.putString(KEY_PHOTO_URI, uri.toString()) + editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString()) + editor.apply() + return uri + } + + + @JvmStatic + private fun createGalleryIntent( + context: Context, + type: Int, + openDocumentIntentPreferred: Boolean + ): Intent { + // storing picked image type to shared preferences + storeType(context, type) + // Supported types are SVG, PNG and JPEG, GIF, TIFF, WebP, XCF + val mimeTypes = arrayOf( + "image/jpg", + "image/png", + "image/jpeg", + "image/gif", + "image/tiff", + "image/webp", + "image/xcf", + "image/svg+xml", + "image/webp" + ) + return plainGalleryPickerIntent(openDocumentIntentPreferred) + .putExtra( + Intent.EXTRA_ALLOW_MULTIPLE, + configuration(context).allowsMultiplePickingInGallery() + ) + .putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) + } + + /** + * CreateCustomSectorIntent, creates intent for custom selector activity. + * @param context + * @param type + * @return Custom selector intent + */ + @JvmStatic + private fun createCustomSelectorIntent(context: Context, type: Int): Intent { + storeType(context, type) + return Intent(context, CustomSelectorActivity::class.java) + } + + @JvmStatic + private fun createCameraForImageIntent(context: Context, type: Int): Intent { + storeType(context, type) + + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + try { + val capturedImageUri = createCameraPictureFile(context) + // We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20 + grantWritePermission(context, intent, capturedImageUri) + intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri) + } catch (e: Exception) { + e.printStackTrace() + } + + return intent + } + + @JvmStatic + private fun revokeWritePermission(context: Context, uri: Uri) { + context.revokeUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + + @JvmStatic + private fun grantWritePermission(context: Context, intent: Intent, uri: Uri) { + val resInfoList = + context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + for (resolveInfo in resInfoList) { + val packageName = resolveInfo.activityInfo.packageName + context.grantUriPermission( + packageName, + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + + @JvmStatic + private fun storeType(context: Context, type: Int) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply() + } + + @JvmStatic + private fun restoreType(context: Context): Int { + return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0) + } + + /** + * Opens default gallery or available galleries picker if there is no default + * + * @param type Custom type of your choice, which will be returned with the images + */ + @JvmStatic + fun openGallery( + activity: Activity, + resultLauncher: ActivityResultLauncher, + type: Int, + openDocumentIntentPreferred: Boolean + ) { + val intent = createGalleryIntent(activity, type, openDocumentIntentPreferred) + resultLauncher.launch(intent) + } + + /** + * Opens Custom Selector + */ + @JvmStatic + fun openCustomSelector( + activity: Activity, + resultLauncher: ActivityResultLauncher, + type: Int + ) { + val intent = createCustomSelectorIntent(activity, type) + resultLauncher.launch(intent) + } + + /** + * Opens the camera app to pick image clicked by user + */ + @JvmStatic + fun openCameraForImage( + activity: Activity, + resultLauncher: ActivityResultLauncher, + type: Int + ) { + val intent = createCameraForImageIntent(activity, type) + resultLauncher.launch(intent) + } + + @Throws(URISyntaxException::class) + @JvmStatic + private fun takenCameraPicture(context: Context): UploadableFile? { + val lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context) + .getString(KEY_LAST_CAMERA_PHOTO, null) + return if (lastCameraPhoto != null) { + UploadableFile(File(lastCameraPhoto)) + } else { + null + } + } + + @Throws(URISyntaxException::class) + @JvmStatic + private fun takenCameraVideo(context: Context): UploadableFile? { + val lastCameraVideo = PreferenceManager.getDefaultSharedPreferences(context) + .getString(KEY_LAST_CAMERA_VIDEO, null) + return if (lastCameraVideo != null) { + UploadableFile(File(lastCameraVideo)) + } else { + null + } + } + + @JvmStatic + fun handleExternalImagesPicked(data: Intent?, activity: Activity): List { + return try { + getFilesFromGalleryPictures(data, activity) + } catch (e: IOException) { + e.printStackTrace() + emptyList() + } catch (e: SecurityException) { + e.printStackTrace() + emptyList() + } + } + + @JvmStatic + private fun isPhoto(data: Intent?): Boolean { + return data == null || (data.data == null && data.clipData == null) + } + + @JvmStatic + private fun plainGalleryPickerIntent( + openDocumentIntentPreferred: Boolean + ): Intent { + /* + * Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue + * in the custom selector in Contributions fragment. + * Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015 + * + * This permission check, however, was insufficient to fix location-loss in + * the regular selector in Contributions fragment and Nearby fragment, + * especially on some devices running Android 13 that use the new Photo Picker by default. + * + * New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker + * + * The new Photo Picker introduced by Android redacts location tags from EXIF metadata. + * Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058 + * Status: Won't fix (Intended behaviour) + * + * Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can + * be changed through the Setting page) as: + * + * ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data + * The best application is the new Photo Picker that redacts the location tags + * + * ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances + * installed on the device, letting the user interactively navigate through them. + * + * So, this allows us to use the traditional file picker that does not redact location tags + * from EXIF. + * + */ + val intent = if (openDocumentIntentPreferred) { + Intent(Intent.ACTION_OPEN_DOCUMENT) + } else { + Intent(Intent.ACTION_GET_CONTENT) + } + intent.type = "image/*" + return intent + } + + @JvmStatic + fun onPictureReturnedFromDocuments( + result: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) { + try { + val photoPath = result.data?.data + val photoFile = PickedFiles.pickedExistingPicture(activity, photoPath!!) + callbacks.onImagesPicked( + singleFileList(photoFile), + ImageSource.DOCUMENTS, + restoreType(activity) + ) + + if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)) + } + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.DOCUMENTS, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.DOCUMENTS, restoreType(activity)) + } + } + + /** + * onPictureReturnedFromCustomSelector. + * Retrieve and forward the images to upload wizard through callback. + */ + @JvmStatic + fun onPictureReturnedFromCustomSelector( + result: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (result.resultCode == Activity.RESULT_OK) { + try { + val files = getFilesFromCustomSelector(result.data, activity) + callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity)) + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity)) + } + } + + /** + * Get files from custom selector + * Retrieve and process the selected images from the custom selector. + */ + @Throws(IOException::class, SecurityException::class) + @JvmStatic + private fun getFilesFromCustomSelector( + data: Intent?, + activity: Activity + ): List { + val files = mutableListOf() + val images = data?.getParcelableArrayListExtra("Images") + images?.forEach { image -> + val uri = image.uri + val file = PickedFiles.pickedExistingPicture(activity, uri) + files.add(file) + } + + if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, files) + } + + return files + } + + @JvmStatic + fun onPictureReturnedFromGallery( + result: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) { + try { + val files = getFilesFromGalleryPictures(result.data, activity) + callbacks.onImagesPicked(files, ImageSource.GALLERY, restoreType(activity)) + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.GALLERY, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.GALLERY, restoreType(activity)) + } + } + + @Throws(IOException::class, SecurityException::class) + @JvmStatic + private fun getFilesFromGalleryPictures( + data: Intent?, + activity: Activity + ): List { + val files = mutableListOf() + val clipData = data?.clipData + if (clipData == null) { + val uri = data?.data + val file = PickedFiles.pickedExistingPicture(activity, uri!!) + files.add(file) + } else { + for (i in 0 until clipData.itemCount) { + val uri = clipData.getItemAt(i).uri + val file = PickedFiles.pickedExistingPicture(activity, uri) + files.add(file) + } + } + + if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, files) + } + + return files + } + + @JvmStatic + fun onPictureReturnedFromCamera( + activityResult: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (activityResult.resultCode == Activity.RESULT_OK) { + try { + val lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity) + .getString(KEY_PHOTO_URI, null) + if (!lastImageUri.isNullOrEmpty()) { + revokeWritePermission(activity, Uri.parse(lastImageUri)) + } + + val photoFile = takenCameraPicture(activity) + val files = mutableListOf() + photoFile?.let { files.add(it) } + + if (photoFile == null) { + val e = IllegalStateException("Unable to get the picture returned from camera") + callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity)) + } else { + if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)) + } + callbacks.onImagesPicked(files, ImageSource.CAMERA_IMAGE, restoreType(activity)) + } + + PreferenceManager.getDefaultSharedPreferences(activity).edit() + .remove(KEY_LAST_CAMERA_PHOTO) + .remove(KEY_PHOTO_URI) + .apply() + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.CAMERA_IMAGE, restoreType(activity)) + } + } + + @JvmStatic + fun configuration(context: Context): FilePickerConfiguration { + return FilePickerConfiguration(context) + } + + enum class ImageSource { + GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR + } + + interface Callbacks { + fun onImagePickerError(e: Exception, source: ImageSource, type: Int) + + fun onImagesPicked(imageFiles: List, source: ImageSource, type: Int) + + fun onCanceled(source: ImageSource, type: Int) + } + + interface HandleActivityResult { + fun onHandleActivityResult(callbacks: Callbacks) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java deleted file mode 100644 index 08a204e8b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java +++ /dev/null @@ -1,44 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.content.Context; -import androidx.preference.PreferenceManager; - -public class FilePickerConfiguration implements Constants { - - private Context context; - - FilePickerConfiguration(Context context) { - this.context = context; - } - - public FilePickerConfiguration setAllowMultiplePickInGallery(boolean allowMultiple) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putBoolean(BundleKeys.ALLOW_MULTIPLE, allowMultiple) - .apply(); - return this; - } - - public FilePickerConfiguration setCopyTakenPhotosToPublicGalleryAppFolder(boolean copy) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putBoolean(BundleKeys.COPY_TAKEN_PHOTOS, copy) - .apply(); - return this; - } - - public String getFolderName() { - return PreferenceManager.getDefaultSharedPreferences(context).getString(BundleKeys.FOLDER_NAME, DEFAULT_FOLDER_NAME); - } - - public boolean allowsMultiplePickingInGallery() { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.ALLOW_MULTIPLE, false); - } - - public boolean shouldCopyTakenPhotosToPublicGalleryAppFolder() { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_TAKEN_PHOTOS, false); - } - - public boolean shouldCopyPickedImagesToPublicGalleryAppFolder() { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_PICKED_IMAGES, false); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt new file mode 100644 index 000000000..db025a544 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.filepicker + +import android.content.Context +import androidx.preference.PreferenceManager + +class FilePickerConfiguration( + private val context: Context +): Constants { + + fun setAllowMultiplePickInGallery(allowMultiple: Boolean): FilePickerConfiguration { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, allowMultiple) + .apply() + return this + } + + fun setCopyTakenPhotosToPublicGalleryAppFolder(copy: Boolean): FilePickerConfiguration { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, copy) + .apply() + return this + } + + fun getFolderName(): String { + return PreferenceManager.getDefaultSharedPreferences(context) + .getString( + Constants.BundleKeys.FOLDER_NAME, + Constants.DEFAULT_FOLDER_NAME + ) ?: Constants.DEFAULT_FOLDER_NAME + } + + fun allowsMultiplePickingInGallery(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, false) + } + + fun shouldCopyTakenPhotosToPublicGalleryAppFolder(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, false) + } + + fun shouldCopyPickedImagesToPublicGalleryAppFolder(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.BundleKeys.COPY_PICKED_IMAGES, false) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java deleted file mode 100644 index e6c82f5c1..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java +++ /dev/null @@ -1,26 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.webkit.MimeTypeMap; - -import com.facebook.common.internal.ImmutableMap; - -import java.util.Map; - -public class MimeTypeMapWrapper { - - private static final MimeTypeMap sMimeTypeMap = MimeTypeMap.getSingleton(); - - private static final Map sMimeTypeToExtensionMap = - ImmutableMap.of( - "image/heif", "heif", - "image/heic", "heic"); - - public static String getExtensionFromMimeType(String mimeType) { - String result = sMimeTypeToExtensionMap.get(mimeType); - if (result != null) { - return result; - } - return sMimeTypeMap.getExtensionFromMimeType(mimeType); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt new file mode 100644 index 000000000..0cf21cc02 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt @@ -0,0 +1,24 @@ +package fr.free.nrw.commons.filepicker + +import android.webkit.MimeTypeMap + +class MimeTypeMapWrapper { + + companion object { + private val sMimeTypeMap = MimeTypeMap.getSingleton() + + private val sMimeTypeToExtensionMap = mapOf( + "image/heif" to "heif", + "image/heic" to "heic" + ) + + @JvmStatic + fun getExtensionFromMimeType(mimeType: String): String? { + val result = sMimeTypeToExtensionMap[mimeType] + if (result != null) { + return result + } + return sMimeTypeMap.getExtensionFromMimeType(mimeType) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java deleted file mode 100644 index ca1abba62..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java +++ /dev/null @@ -1,208 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.content.ContentResolver; -import android.content.Context; -import android.media.MediaScannerConnection; -import android.net.Uri; -import android.os.Environment; -import android.webkit.MimeTypeMap; - -import androidx.annotation.NonNull; -import androidx.core.content.FileProvider; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; -import java.util.UUID; - -import timber.log.Timber; - -/** - * PickedFiles. - * Process the upload items. - */ -public class PickedFiles implements Constants { - - /** - * Get Folder Name - * @param context - * @return default application folder name. - */ - private static String getFolderName(@NonNull Context context) { - return FilePicker.configuration(context).getFolderName(); - } - - /** - * tempImageDirectory - * @param context - * @return temporary image directory to copy and perform exif changes. - */ - private static File tempImageDirectory(@NonNull Context context) { - File privateTempDir = new File(context.getCacheDir(), DEFAULT_FOLDER_NAME); - if (!privateTempDir.exists()) privateTempDir.mkdirs(); - return privateTempDir; - } - - /** - * writeToFile - * writes inputStream data to the destination file. - * @param in input stream of source file. - * @param file destination file - */ - private static void writeToFile(InputStream in, File file) throws IOException { - try (OutputStream out = new FileOutputStream(file)) { - byte[] buf = new byte[1024]; - int len; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - } - } - } - - /** - * Copy file function. - * Copies source file to destination file. - * @param src source file - * @param dst destination file - * @throws IOException (File input stream exception) - */ - private static void copyFile(File src, File dst) throws IOException { - try (InputStream in = new FileInputStream(src)) { - writeToFile(in, dst); - } - } - - /** - * Copy files in separate thread. - * Copies all the uploadable files to the temp image folder on background thread. - * @param context - * @param filesToCopy uploadable file list to be copied. - */ - static void copyFilesInSeparateThread(final Context context, final List filesToCopy) { - new Thread(() -> { - List copiedFiles = new ArrayList<>(); - int i = 1; - for (UploadableFile uploadableFile : filesToCopy) { - File fileToCopy = uploadableFile.getFile(); - File dstDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), getFolderName(context)); - if (!dstDir.exists()) { - dstDir.mkdirs(); - } - - String[] filenameSplit = fileToCopy.getName().split("\\."); - String extension = "." + filenameSplit[filenameSplit.length - 1]; - String filename = String.format("IMG_%s_%d.%s", new SimpleDateFormat("yyyyMMdd_HHmmss").format(Calendar.getInstance().getTime()), i, extension); - - File dstFile = new File(dstDir, filename); - try { - dstFile.createNewFile(); - copyFile(fileToCopy, dstFile); - copiedFiles.add(dstFile); - } catch (IOException e) { - e.printStackTrace(); - } - i++; - } - scanCopiedImages(context, copiedFiles); - }).run(); - } - - /** - * singleFileList. - * converts a single uploadableFile to list of uploadableFile. - * @param file uploadable file - * @return - */ - static List singleFileList(UploadableFile file) { - List list = new ArrayList<>(); - list.add(file); - return list; - } - - /** - * ScanCopiedImages - * Scan copied images metadata using media scanner. - * @param context - * @param copiedImages copied images list. - */ - static void scanCopiedImages(Context context, List copiedImages) { - String[] paths = new String[copiedImages.size()]; - for (int i = 0; i < copiedImages.size(); i++) { - paths[i] = copiedImages.get(i).toString(); - } - - MediaScannerConnection.scanFile(context, - paths, null, - (path, uri) -> { - Timber.d("Scanned " + path + ":"); - Timber.d("-> uri=%s", uri); - }); - } - - /** - * pickedExistingPicture - * convert the image into uploadable file. - * @param photoUri Uri of the image. - * @return Uploadable file ready for tag redaction. - */ - public static UploadableFile pickedExistingPicture(@NonNull Context context, Uri photoUri) throws IOException, SecurityException {// SecurityException for those file providers who share URI but forget to grant necessary permissions - File directory = tempImageDirectory(context); - File photoFile = new File(directory, UUID.randomUUID().toString() + "." + getMimeType(context, photoUri)); - if (photoFile.createNewFile()) { - try (InputStream pictureInputStream = context.getContentResolver().openInputStream(photoUri)) { - writeToFile(pictureInputStream, photoFile); - } - } else { - throw new IOException("could not create photoFile to write upon"); - } - return new UploadableFile(photoUri, photoFile); - } - - /** - * getCameraPictureLocation - */ - static File getCameraPicturesLocation(@NonNull Context context) throws IOException { - File dir = tempImageDirectory(context); - return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir); - } - - /** - * To find out the extension of required object in given uri - * Solution by http://stackoverflow.com/a/36514823/1171484 - */ - private static String getMimeType(@NonNull Context context, @NonNull Uri uri) { - String extension; - - //Check uri format to avoid null - if (uri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) { - //If scheme is a content - extension = MimeTypeMapWrapper.getExtensionFromMimeType(context.getContentResolver().getType(uri)); - } else { - //If scheme is a File - //This will replace white spaces with %20 and also other special characters. This will avoid returning null values on file name with spaces and special characters. - extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(uri.getPath())).toString()); - - } - - return extension; - } - - /** - * GetUriToFile - * @param file get uri of file - * @return uri of requested file. - */ - static Uri getUriToFile(@NonNull Context context, @NonNull File file) { - String packageName = context.getApplicationContext().getPackageName(); - String authority = packageName + ".provider"; - return FileProvider.getUriForFile(context, authority, file); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt new file mode 100644 index 000000000..9694dedb5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt @@ -0,0 +1,195 @@ +package fr.free.nrw.commons.filepicker + +import android.content.ContentResolver +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Environment +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import fr.free.nrw.commons.filepicker.Constants.Companion.DEFAULT_FOLDER_NAME +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID + + +/** + * PickedFiles. + * Process the upload items. + */ +object PickedFiles : Constants { + + /** + * Get Folder Name + * @return default application folder name. + */ + @JvmStatic + private fun getFolderName(context: Context): String { + return FilePicker.configuration(context).getFolderName() + } + + /** + * tempImageDirectory + * @return temporary image directory to copy and perform exif changes. + */ + @JvmStatic + private fun tempImageDirectory(context: Context): File { + val privateTempDir = File(context.cacheDir, DEFAULT_FOLDER_NAME) + if (!privateTempDir.exists()) privateTempDir.mkdirs() + return privateTempDir + } + + /** + * writeToFile + * Writes inputStream data to the destination file. + */ + @JvmStatic + @Throws(IOException::class) + private fun writeToFile(inputStream: InputStream, file: File) { + inputStream.use { input -> + FileOutputStream(file).use { output -> + val buffer = ByteArray(1024) + var length: Int + while (input.read(buffer).also { length = it } > 0) { + output.write(buffer, 0, length) + } + } + } + } + + /** + * Copy file function. + * Copies source file to destination file. + */ + @Throws(IOException::class) + @JvmStatic + private fun copyFile(src: File, dst: File) { + FileInputStream(src).use { inputStream -> + writeToFile(inputStream, dst) + } + } + + /** + * Copy files in separate thread. + * Copies all the uploadable files to the temp image folder on background thread. + */ + @JvmStatic + fun copyFilesInSeparateThread(context: Context, filesToCopy: List) { + Thread { + val copiedFiles = mutableListOf() + var index = 1 + filesToCopy.forEach { uploadableFile -> + val fileToCopy = uploadableFile.file + val dstDir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + getFolderName(context) + ) + if (!dstDir.exists()) dstDir.mkdirs() + + val filenameSplit = fileToCopy.name.split(".") + val extension = ".${filenameSplit.last()}" + val filename = "IMG_${SimpleDateFormat( + "yyyyMMdd_HHmmss", + Locale.getDefault()).format(Date())}_$index$extension" + val dstFile = File(dstDir, filename) + + try { + dstFile.createNewFile() + copyFile(fileToCopy, dstFile) + copiedFiles.add(dstFile) + } catch (e: IOException) { + e.printStackTrace() + } + index++ + } + scanCopiedImages(context, copiedFiles) + }.start() + } + + /** + * singleFileList + * Converts a single uploadableFile to list of uploadableFile. + */ + @JvmStatic + fun singleFileList(file: UploadableFile): List { + return listOf(file) + } + + /** + * ScanCopiedImages + * Scans copied images metadata using media scanner. + */ + @JvmStatic + fun scanCopiedImages(context: Context, copiedImages: List) { + val paths = copiedImages.map { it.toString() }.toTypedArray() + MediaScannerConnection.scanFile(context, paths, null) { path, uri -> + Timber.d("Scanned $path:") + Timber.d("-> uri=$uri") + } + } + + /** + * pickedExistingPicture + * Convert the image into uploadable file. + */ + @Throws(IOException::class, SecurityException::class) + @JvmStatic + fun pickedExistingPicture(context: Context, photoUri: Uri): UploadableFile { + val directory = tempImageDirectory(context) + val mimeType = getMimeType(context, photoUri) + val photoFile = File(directory, "${UUID.randomUUID()}.$mimeType") + + if (photoFile.createNewFile()) { + context.contentResolver.openInputStream(photoUri)?.use { inputStream -> + writeToFile(inputStream, photoFile) + } + } else { + throw IOException("Could not create photoFile to write upon") + } + return UploadableFile(photoUri, photoFile) + } + + /** + * getCameraPictureLocation + */ + @Throws(IOException::class) + @JvmStatic + fun getCameraPicturesLocation(context: Context): File { + val dir = tempImageDirectory(context) + return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir) + } + + /** + * To find out the extension of the required object in a given uri + */ + @JvmStatic + private fun getMimeType(context: Context, uri: Uri): String { + return if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + context.contentResolver.getType(uri) + ?.let { MimeTypeMapWrapper.getExtensionFromMimeType(it) } + } else { + MimeTypeMap.getFileExtensionFromUrl( + Uri.fromFile(uri.path?.let { File(it) }).toString() + ) + } ?: "jpg" // Default to jpg if unable to determine type + } + + /** + * GetUriToFile + * @param file get uri of file + * @return uri of requested file. + */ + @JvmStatic + fun getUriToFile(context: Context, file: File): Uri { + val packageName = context.applicationContext.packageName + val authority = "$packageName.provider" + return FileProvider.getUriForFile(context, authority, file) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java deleted file mode 100644 index 1fe306a8b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java +++ /dev/null @@ -1,213 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.Nullable; -import androidx.exifinterface.media.ExifInterface; - -import fr.free.nrw.commons.upload.FileUtils; -import java.io.File; -import java.io.IOException; -import java.util.Date; -import timber.log.Timber; - -public class UploadableFile implements Parcelable { - public static final Creator CREATOR = new Creator() { - @Override - public UploadableFile createFromParcel(Parcel in) { - return new UploadableFile(in); - } - - @Override - public UploadableFile[] newArray(int size) { - return new UploadableFile[size]; - } - }; - - private final Uri contentUri; - private final File file; - - public UploadableFile(Uri contentUri, File file) { - this.contentUri = contentUri; - this.file = file; - } - - public UploadableFile(File file) { - this.file = file; - this.contentUri = Uri.fromFile(new File(file.getPath())); - } - - public UploadableFile(Parcel in) { - this.contentUri = in.readParcelable(Uri.class.getClassLoader()); - file = (File) in.readSerializable(); - } - - public Uri getContentUri() { - return contentUri; - } - - public File getFile() { - return file; - } - - public String getFilePath() { - return file.getPath(); - } - - public Uri getMediaUri() { - return Uri.parse(getFilePath()); - } - - public String getMimeType(Context context) { - return FileUtils.getMimeType(context, getMediaUri()); - } - - @Override - public int describeContents() { - return 0; - } - - /** - * First try to get the file creation date from EXIF else fall back to CP - * @param context - * @return - */ - @Nullable - public DateTimeWithSource getFileCreatedDate(Context context) { - DateTimeWithSource dateTimeFromExif = getDateTimeFromExif(); - if (dateTimeFromExif == null) { - return getFileCreatedDateFromCP(context); - } else { - return dateTimeFromExif; - } - } - - /** - * Get filePath creation date from uri from all possible content providers - * - * @return - */ - private DateTimeWithSource getFileCreatedDateFromCP(Context context) { - try { - Cursor cursor = context.getContentResolver().query(contentUri, null, null, null, null); - if (cursor == null) { - return null;//Could not fetch last_modified - } - //Content provider contracts for opening gallery from the app and that by sharing from gallery from outside are different and we need to handle both the cases - int lastModifiedColumnIndex = cursor.getColumnIndex("last_modified");//If gallery is opened from in app - if (lastModifiedColumnIndex == -1) { - lastModifiedColumnIndex = cursor.getColumnIndex("datetaken"); - } - //If both the content providers do not give the data, lets leave it to Jesus - if (lastModifiedColumnIndex == -1) { - cursor.close(); - return null; - } - cursor.moveToFirst(); - return new DateTimeWithSource(cursor.getLong(lastModifiedColumnIndex), DateTimeWithSource.CP_SOURCE); - } catch (Exception e) { - return null;////Could not fetch last_modified - } - } - - /** - * Indicate whether the EXIF contains the location (both latitude and longitude). - * - * @return whether the location exists for the file's EXIF - */ - public boolean hasLocation() { - try { - ExifInterface exif = new ExifInterface(file.getAbsolutePath()); - final String latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE); - final String longitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE); - return latitude != null && longitude != null; - } catch (IOException | NumberFormatException | IndexOutOfBoundsException e) { - Timber.tag("UploadableFile"); - Timber.d(e); - } - return false; - } - - /** - * Get filePath creation date from uri from EXIF - * - * @return - */ - private DateTimeWithSource getDateTimeFromExif() { - try { - ExifInterface exif = new ExifInterface(file.getAbsolutePath()); - // TAG_DATETIME returns the last edited date, we need TAG_DATETIME_ORIGINAL for creation date - // See issue https://github.com/commons-app/apps-android-commons/issues/1971 - String dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL); - if (dateTimeSubString!=null) { //getAttribute may return null - String year = dateTimeSubString.substring(0,4); - String month = dateTimeSubString.substring(5,7); - String day = dateTimeSubString.substring(8,10); - // This date is stored as a string (not as a date), the rason is we don't want to include timezones - String dateCreatedString = String.format("%04d-%02d-%02d", Integer.parseInt(year), Integer.parseInt(month), Integer.parseInt(day)); - if (dateCreatedString.length() == 10) { //yyyy-MM-dd format of date is expected - @SuppressLint("RestrictedApi") Long dateTime = exif.getDateTimeOriginal(); - if(dateTime != null){ - Date date = new Date(dateTime); - return new DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE); - } - } - } - } catch (IOException | NumberFormatException | IndexOutOfBoundsException e) { - Timber.tag("UploadableFile"); - Timber.d(e); - } - return null; - } - - @Override - public void writeToParcel(Parcel parcel, int i) { - parcel.writeParcelable(contentUri, 0); - parcel.writeSerializable(file); - } - - /** - * This class contains the epochDate along with the source from which it was extracted - */ - public class DateTimeWithSource { - public static final String CP_SOURCE = "contentProvider"; - public static final String EXIF_SOURCE = "exif"; - - private final long epochDate; - private String dateString; // this does not includes timezone information - private final String source; - - public DateTimeWithSource(long epochDate, String source) { - this.epochDate = epochDate; - this.source = source; - } - - public DateTimeWithSource(Date date, String source) { - this.epochDate = date.getTime(); - this.source = source; - } - - public DateTimeWithSource(Date date, String dateString, String source) { - this.epochDate = date.getTime(); - this.dateString = dateString; - this.source = source; - } - - public long getEpochDate() { - return epochDate; - } - - public String getDateString() { - return dateString; - } - - public String getSource() { - return source; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt new file mode 100644 index 000000000..1398e7785 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt @@ -0,0 +1,168 @@ +package fr.free.nrw.commons.filepicker + +import android.annotation.SuppressLint +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable + +import androidx.exifinterface.media.ExifInterface + +import fr.free.nrw.commons.upload.FileUtils +import java.io.File +import java.io.IOException +import java.util.Date +import timber.log.Timber + +class UploadableFile : Parcelable { + + val contentUri: Uri + val file: File + + constructor(contentUri: Uri, file: File) { + this.contentUri = contentUri + this.file = file + } + + constructor(file: File) { + this.file = file + this.contentUri = Uri.fromFile(File(file.path)) + } + + private constructor(parcel: Parcel) { + contentUri = parcel.readParcelable(Uri::class.java.classLoader)!! + file = parcel.readSerializable() as File + } + + fun getFilePath(): String { + return file.path + } + + fun getMediaUri(): Uri { + return Uri.parse(getFilePath()) + } + + fun getMimeType(context: Context): String? { + return FileUtils.getMimeType(context, getMediaUri()) + } + + override fun describeContents(): Int = 0 + + /** + * First try to get the file creation date from EXIF, else fall back to Content Provider (CP) + */ + fun getFileCreatedDate(context: Context): DateTimeWithSource? { + return getDateTimeFromExif() ?: getFileCreatedDateFromCP(context) + } + + /** + * Get filePath creation date from URI using all possible content providers + */ + private fun getFileCreatedDateFromCP(context: Context): DateTimeWithSource? { + return try { + val cursor: Cursor? = context.contentResolver.query(contentUri, null, null, null, null) + cursor?.use { + val lastModifiedColumnIndex = cursor + .getColumnIndex( + "last_modified" + ).takeIf { it != -1 } + ?: cursor.getColumnIndex("datetaken") + if (lastModifiedColumnIndex == -1) return null // No valid column found + cursor.moveToFirst() + DateTimeWithSource( + cursor.getLong( + lastModifiedColumnIndex + ), DateTimeWithSource.CP_SOURCE) + } + } catch (e: Exception) { + Timber.tag("UploadableFile").d(e) + null + } + } + + /** + * Indicates whether the EXIF contains the location (both latitude and longitude). + */ + fun hasLocation(): Boolean { + return try { + val exif = ExifInterface(file.absolutePath) + val latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE) + val longitude = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE) + latitude != null && longitude != null + } catch (e: IOException) { + Timber.tag("UploadableFile").d(e) + false + } + } + + /** + * Get filePath creation date from URI using EXIF data + */ + private fun getDateTimeFromExif(): DateTimeWithSource? { + return try { + val exif = ExifInterface(file.absolutePath) + val dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + if (dateTimeSubString != null) { + val year = dateTimeSubString.substring(0, 4).toInt() + val month = dateTimeSubString.substring(5, 7).toInt() + val day = dateTimeSubString.substring(8, 10).toInt() + val dateCreatedString = "%04d-%02d-%02d".format(year, month, day) + if (dateCreatedString.length == 10) { + @SuppressLint("RestrictedApi") + val dateTime = exif.dateTimeOriginal + if (dateTime != null) { + val date = Date(dateTime) + return DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE) + } + } + } + null + } catch (e: Exception) { + Timber.tag("UploadableFile").d(e) + null + } + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(contentUri, flags) + parcel.writeSerializable(file) + } + + class DateTimeWithSource { + companion object { + const val CP_SOURCE = "contentProvider" + const val EXIF_SOURCE = "exif" + } + + val epochDate: Long + var dateString: String? = null + val source: String + + constructor(epochDate: Long, source: String) { + this.epochDate = epochDate + this.source = source + } + + constructor(date: Date, source: String) { + epochDate = date.time + this.source = source + } + + constructor(date: Date, dateString: String, source: String) { + epochDate = date.time + this.dateString = dateString + this.source = source + } + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): UploadableFile { + return UploadableFile(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesResponse.kt b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesResponse.kt new file mode 100644 index 000000000..96d19d1cf --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesResponse.kt @@ -0,0 +1,35 @@ +package fr.free.nrw.commons.fileusages + +import com.google.gson.annotations.SerializedName + +/** + * Show where file is being used on Commons and oher wikis. + */ +data class FileUsagesResponse( + @SerializedName("continue") val continueResponse: CommonsContinue?, + @SerializedName("batchcomplete") val batchComplete: Boolean, + @SerializedName("query") val query: Query, +) + +data class CommonsContinue( + @SerializedName("fucontinue") val fuContinue: String, + @SerializedName("continue") val continueKey: String +) + +data class Query( + @SerializedName("pages") val pages: List +) + +data class Page( + @SerializedName("pageid") val pageId: Int, + @SerializedName("ns") val nameSpace: Int, + @SerializedName("title") val title: String, + @SerializedName("fileusage") val fileUsage: List +) + +data class FileUsage( + @SerializedName("pageid") val pageId: Int, + @SerializedName("ns") val nameSpace: Int, + @SerializedName("title") val title: String, + @SerializedName("redirect") val redirect: Boolean +) diff --git a/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesUiModel.kt b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesUiModel.kt new file mode 100644 index 000000000..63b0740d0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesUiModel.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.fileusages + +/** + * Show where file is being used on Commons and oher wikis. + */ +data class FileUsagesUiModel( + val title: String, + val link: String? +) + +fun FileUsage.toUiModel(): FileUsagesUiModel { + return FileUsagesUiModel(title = title, link = "https://commons.wikimedia.org/wiki/$title") +} + +fun GlobalFileUsage.toUiModel(): FileUsagesUiModel { + // link is associated with sub items under wiki group (which is not used ATM) + return FileUsagesUiModel(title = wiki, link = null) +} diff --git a/app/src/main/java/fr/free/nrw/commons/fileusages/GlobalFileUsagesResponse.kt b/app/src/main/java/fr/free/nrw/commons/fileusages/GlobalFileUsagesResponse.kt new file mode 100644 index 000000000..17580539e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/fileusages/GlobalFileUsagesResponse.kt @@ -0,0 +1,34 @@ +package fr.free.nrw.commons.fileusages + +import com.google.gson.annotations.SerializedName + +/** + * Show where file is being used on Commons and oher wikis. + */ +data class GlobalFileUsagesResponse( + @SerializedName("continue") val continueResponse: GlobalContinue?, + @SerializedName("batchcomplete") val batchComplete: Boolean, + @SerializedName("query") val query: GlobalQuery, +) + +data class GlobalContinue( + @SerializedName("gucontinue") val guContinue: String, + @SerializedName("continue") val continueKey: String +) + +data class GlobalQuery( + @SerializedName("pages") val pages: List +) + +data class GlobalPage( + @SerializedName("pageid") val pageId: Int, + @SerializedName("ns") val nameSpace: Int, + @SerializedName("title") val title: String, + @SerializedName("globalusage") val fileUsage: List +) + +data class GlobalFileUsage( + @SerializedName("title") val title: String, + @SerializedName("wiki") val wiki: String, + @SerializedName("url") val url: String +) diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.java b/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.java deleted file mode 100644 index 032898896..000000000 --- a/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.java +++ /dev/null @@ -1,215 +0,0 @@ -package fr.free.nrw.commons.kvstore; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.Nullable; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -import timber.log.Timber; - -public class BasicKvStore implements KeyValueStore { - private static final String KEY_VERSION = "__version__"; - /* - This class only performs puts, sets and clears. - A commit returns a boolean indicating whether it has succeeded, we are not throwing an exception as it will - require the dev to handle it in every usage - instead we will pass on this boolean so it can be evaluated if needed. - */ - private final SharedPreferences _store; - - public BasicKvStore(Context context, String storeName) { - _store = context.getSharedPreferences(storeName, Context.MODE_PRIVATE); - } - - /** - * If you don't want onVersionUpdate to be called on a fresh creation, the first version supplied for the kvstore should be set to 0. - */ - public BasicKvStore(Context context, String storeName, int version) { - this(context,storeName,version,false); - } - - public BasicKvStore(Context context, String storeName, int version, boolean clearAllOnUpgrade) { - _store = context.getSharedPreferences(storeName, Context.MODE_PRIVATE); - int oldVersion = getInt(KEY_VERSION); - - if (version > oldVersion) { - Timber.i("version updated from %s to %s, with clearFlag %b", oldVersion, version, clearAllOnUpgrade); - onVersionUpdate(oldVersion, version, clearAllOnUpgrade); - } - - if (version < oldVersion) { - throw new IllegalArgumentException( - "kvstore downgrade not allowed, old version:" + oldVersion + ", new version: " + - version); - } - //Keep this statement at the end so that clearing of store does not cause version also to get removed. - putIntInternal(KEY_VERSION, version); - } - - public void onVersionUpdate(int oldVersion, int version, boolean clearAllFlag) { - if(clearAllFlag) { - clearAll(); - } - } - - public Set getKeySet() { - Map allContents = new HashMap<>(_store.getAll()); - allContents.remove(KEY_VERSION); - return allContents.keySet(); - } - - @Nullable - public Map getAll() { - Map allContents = _store.getAll(); - if (allContents == null || allContents.size() == 0) { - return null; - } - allContents.remove(KEY_VERSION); - return new HashMap<>(allContents); - } - - @Override - public String getString(String key) { - return getString(key, null); - } - - @Override - public boolean getBoolean(String key) { - return getBoolean(key, false); - } - - @Override - public long getLong(String key) { - return getLong(key, 0); - } - - @Override - public int getInt(String key) { - return getInt(key, 0); - } - - @Override - public String getString(String key, String defaultValue) { - return _store.getString(key, defaultValue); - } - - @Override - public boolean getBoolean(String key, boolean defaultValue) { - return _store.getBoolean(key, defaultValue); - } - - @Override - public long getLong(String key, long defaultValue) { - return _store.getLong(key, defaultValue); - } - - @Override - public int getInt(String key, int defaultValue) { - return _store.getInt(key, defaultValue); - } - - public void putAllStrings(Map keyValuePairs) { - SharedPreferences.Editor editor = _store.edit(); - for (Map.Entry keyValuePair : keyValuePairs.entrySet()) { - putString(editor, keyValuePair.getKey(), keyValuePair.getValue(), false); - } - editor.apply(); - } - - @Override - public void putString(String key, String value) { - SharedPreferences.Editor editor = _store.edit(); - putString(editor, key, value, true); - } - - private void putString(SharedPreferences.Editor editor, String key, String value, - boolean commit) { - assertKeyNotReserved(key); - editor.putString(key, value); - if(commit) { - editor.apply(); - } - } - - @Override - public void putBoolean(String key, boolean value) { - assertKeyNotReserved(key); - SharedPreferences.Editor editor = _store.edit(); - editor.putBoolean(key, value); - editor.apply(); - } - - @Override - public void putLong(String key, long value) { - assertKeyNotReserved(key); - SharedPreferences.Editor editor = _store.edit(); - editor.putLong(key, value); - editor.apply(); - } - - @Override - public void putInt(String key, int value) { - assertKeyNotReserved(key); - putIntInternal(key, value); - } - - @Override - public boolean contains(String key) { - return _store.contains(key); - } - - @Override - public void remove(String key) { - SharedPreferences.Editor editor = _store.edit(); - editor.remove(key); - editor.apply(); - } - - @Override - public void clearAll() { - int version = getInt(KEY_VERSION); - SharedPreferences.Editor editor = _store.edit(); - editor.clear(); - editor.apply(); - putIntInternal(KEY_VERSION, version); - } - - @Override - public void clearAllWithVersion() { - SharedPreferences.Editor editor = _store.edit(); - editor.clear(); - editor.apply(); - } - - private void putIntInternal(String key, int value) { - SharedPreferences.Editor editor = _store.edit(); - editor.putInt(key, value); - editor.apply(); - } - - private void assertKeyNotReserved(String key) { - if (key.equals(KEY_VERSION)) { - throw new IllegalArgumentException(key + "is a reserved key"); - } - } - - public void registerChangeListener(SharedPreferences.OnSharedPreferenceChangeListener l) { - _store.registerOnSharedPreferenceChangeListener(l); - } - - public void unregisterChangeListener(SharedPreferences.OnSharedPreferenceChangeListener l) { - _store.unregisterOnSharedPreferenceChangeListener(l); - } - - public Set getStringSet(String key){ - return _store.getStringSet(key, new HashSet<>()); - } - - public void putStringSet(String key,Set value){ - _store.edit().putStringSet(key,value).apply(); - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.kt b/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.kt new file mode 100644 index 000000000..e0b860164 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.kt @@ -0,0 +1,152 @@ +package fr.free.nrw.commons.kvstore + +import android.content.Context +import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting +import androidx.core.content.edit +import timber.log.Timber + +open class BasicKvStore : KeyValueStore { + /* + This class only performs puts, sets and clears. + A commit returns a boolean indicating whether it has succeeded, we are not throwing an exception as it will + require the dev to handle it in every usage - instead we will pass on this boolean so it can be evaluated if needed. + */ + private val _store: SharedPreferences + + constructor(context: Context, storeName: String?) { + _store = context.getSharedPreferences(storeName, Context.MODE_PRIVATE) + } + + /** + * If you don't want onVersionUpdate to be called on a fresh creation, the first version supplied for the kvstore should be set to 0. + */ + @JvmOverloads + constructor( + context: Context, + storeName: String?, + version: Int, + clearAllOnUpgrade: Boolean = false + ) { + _store = context.getSharedPreferences(storeName, Context.MODE_PRIVATE) + val oldVersion = _store.getInt(KEY_VERSION, 0) + + require(version >= oldVersion) { + "kvstore downgrade not allowed, old version:" + oldVersion + ", new version: " + + version + } + + if (version > oldVersion) { + Timber.i( + "version updated from %s to %s, with clearFlag %b", + oldVersion, + version, + clearAllOnUpgrade + ) + onVersionUpdate(oldVersion, version, clearAllOnUpgrade) + } + + //Keep this statement at the end so that clearing of store does not cause version also to get removed. + _store.edit { putInt(KEY_VERSION, version) } + } + + val all: Map? + get() { + val allContents = _store.all + if (allContents == null || allContents.isEmpty()) { + return null + } + allContents.remove(KEY_VERSION) + return HashMap(allContents) + } + + override fun getString(key: String): String? = + getString(key, null) + + override fun getBoolean(key: String): Boolean = + getBoolean(key, false) + + override fun getLong(key: String): Long = + getLong(key, 0) + + override fun getInt(key: String): Int = + getInt(key, 0) + + fun getStringSet(key: String?): MutableSet = + _store.getStringSet(key, HashSet())!! + + override fun getString(key: String, defaultValue: String?): String? = + _store.getString(key, defaultValue) + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean = + _store.getBoolean(key, defaultValue) + + override fun getLong(key: String, defaultValue: Long): Long = + _store.getLong(key, defaultValue) + + override fun getInt(key: String, defaultValue: Int): Int = + _store.getInt(key, defaultValue) + + fun putAllStrings(kvData: Map) = assertKeyNotReserved(kvData.keys) { + for ((key, value) in kvData) { + putString(key, value) + } + } + + override fun putString(key: String, value: String) = assertKeyNotReserved(key) { + putString(key, value) + } + + override fun putBoolean(key: String, value: Boolean) = assertKeyNotReserved(key) { + putBoolean(key, value) + } + + override fun putLong(key: String, value: Long) = assertKeyNotReserved(key) { + putLong(key, value) + } + + override fun putInt(key: String, value: Int) = assertKeyNotReserved(key) { + putInt(key, value) + } + + fun putStringSet(key: String?, value: Set?) = + _store.edit{ putStringSet(key, value) } + + override fun remove(key: String) = assertKeyNotReserved(key) { + remove(key) + } + + override fun contains(key: String): Boolean { + if (key == KEY_VERSION) return false + return _store.contains(key) + } + + override fun clearAll() { + val version = _store.getInt(KEY_VERSION, 0) + _store.edit { + clear() + putInt(KEY_VERSION, version) + } + } + + private fun onVersionUpdate(oldVersion: Int, version: Int, clearAllFlag: Boolean) { + if (clearAllFlag) { + clearAll() + } + } + + protected fun assertKeyNotReserved(key: Set, block: SharedPreferences.Editor.() -> Unit) { + key.forEach { require(it != KEY_VERSION) { "$it is a reserved key" } } + _store.edit { block(this) } + } + + protected fun assertKeyNotReserved(key: String, block: SharedPreferences.Editor.() -> Unit) { + require(key != KEY_VERSION) { "$key is a reserved key" } + _store.edit { block(this) } + } + + companion object { + @VisibleForTesting + const val KEY_VERSION: String = "__version__" + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.java b/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.java deleted file mode 100644 index d612880d9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.java +++ /dev/null @@ -1,68 +0,0 @@ -package fr.free.nrw.commons.kvstore; - -import android.content.Context; - -import androidx.annotation.Nullable; - -import com.google.gson.Gson; -import com.google.gson.JsonSyntaxException; - -import java.lang.reflect.Type; -import java.util.HashMap; -import java.util.Map; - -public class JsonKvStore extends BasicKvStore { - private final Gson gson; - - public JsonKvStore(Context context, String storeName, Gson gson) { - super(context, storeName); - this.gson = gson; - } - - public JsonKvStore(Context context, String storeName, int version, Gson gson) { - super(context, storeName, version); - this.gson = gson; - } - - public JsonKvStore(Context context, String storeName, int version, boolean clearAllOnUpgrade, Gson gson) { - super(context, storeName, version, clearAllOnUpgrade); - this.gson = gson; - } - - public void putAllJsons(Map jsonMap) { - Map stringsMap = new HashMap<>(jsonMap.size()); - for (Map.Entry keyValuePair : jsonMap.entrySet()) { - String jsonString = gson.toJson(keyValuePair.getValue()); - stringsMap.put(keyValuePair.getKey(), jsonString); - } - putAllStrings(stringsMap); - } - - public void putJson(String key, T object) { - putString(key, gson.toJson(object)); - } - - public void putJsonWithTypeInfo(String key, T object, Type type) { - putString(key, gson.toJson(object, type)); - } - - @Nullable - public T getJson(String key, Class clazz) { - String jsonString = getString(key); - try { - return gson.fromJson(jsonString, clazz); - } catch (JsonSyntaxException e) { - return null; - } - } - - @Nullable - public T getJson(String key, Type type) { - String jsonString = getString(key); - try { - return gson.fromJson(jsonString, type); - } catch (JsonSyntaxException e) { - return null; - } - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.kt b/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.kt new file mode 100644 index 000000000..0f46222a4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.kt @@ -0,0 +1,52 @@ +package fr.free.nrw.commons.kvstore + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException + +class JsonKvStore : BasicKvStore { + val gson: Gson + + constructor(context: Context, storeName: String?, gson: Gson) : super(context, storeName) { + this.gson = gson + } + + constructor(context: Context, storeName: String?, version: Int, gson: Gson) : super( + context, storeName, version + ) { + this.gson = gson + } + + constructor( + context: Context, + storeName: String?, + version: Int, + clearAllOnUpgrade: Boolean, + gson: Gson + ) : super(context, storeName, version, clearAllOnUpgrade) { + this.gson = gson + } + + fun putJson(key: String, value: T) = assertKeyNotReserved(key) { + putString(key, gson.toJson(value)) + } + + @Deprecated( + message = "Migrate to newer Kotlin syntax", + replaceWith = ReplaceWith("getJson(key)") + ) + fun getJson(key: String, clazz: Class?): T? = try { + gson.fromJson(getString(key), clazz) + } catch (e: JsonSyntaxException) { + null + } + + // Later, when the calls are coming from Kotlin, this will allow us to + // drop the "clazz" parameter, and just pick up the type at the call site. + // The deprecation warning should help migration! + inline fun getJson(key: String): T? = try { + gson.fromJson(getString(key), T::class.java) + } catch (e: JsonSyntaxException) { + null + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.java b/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.java deleted file mode 100644 index 46d6d8f81..000000000 --- a/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.java +++ /dev/null @@ -1,35 +0,0 @@ -package fr.free.nrw.commons.kvstore; - -public interface KeyValueStore { - String getString(String key); - - boolean getBoolean(String key); - - long getLong(String key); - - int getInt(String key); - - String getString(String key, String defaultValue); - - boolean getBoolean(String key, boolean defaultValue); - - long getLong(String key, long defaultValue); - - int getInt(String key, int defaultValue); - - void putString(String key, String value); - - void putBoolean(String key, boolean value); - - void putLong(String key, long value); - - void putInt(String key, int value); - - boolean contains(String key); - - void remove(String key); - - void clearAll(); - - void clearAllWithVersion(); -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.kt b/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.kt new file mode 100644 index 000000000..6e19901cb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.kt @@ -0,0 +1,33 @@ +package fr.free.nrw.commons.kvstore + +interface KeyValueStore { + fun getString(key: String): String? + + fun getBoolean(key: String): Boolean + + fun getLong(key: String): Long + + fun getInt(key: String): Int + + fun getString(key: String, defaultValue: String?): String? + + fun getBoolean(key: String, defaultValue: Boolean): Boolean + + fun getLong(key: String, defaultValue: Long): Long + + fun getInt(key: String, defaultValue: Int): Int + + fun putString(key: String, value: String) + + fun putBoolean(key: String, value: Boolean) + + fun putLong(key: String, value: Long) + + fun putInt(key: String, value: Int) + + fun contains(key: String): Boolean + + fun remove(key: String) + + fun clearAll() +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.java b/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.java deleted file mode 100644 index a0286a7ef..000000000 --- a/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.java +++ /dev/null @@ -1,141 +0,0 @@ -package fr.free.nrw.commons.language; - -import android.content.Context; -import android.content.res.Resources; -import android.text.TextUtils; - -import androidx.annotation.ArrayRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import fr.free.nrw.commons.R; -import java.lang.ref.SoftReference; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; - -/** Immutable look up table for all app supported languages. All article languages may not be - * present in this table as it is statically bundled with the app. */ -public class AppLanguageLookUpTable { - public static final String SIMPLIFIED_CHINESE_LANGUAGE_CODE = "zh-hans"; - public static final String TRADITIONAL_CHINESE_LANGUAGE_CODE = "zh-hant"; - public static final String CHINESE_CN_LANGUAGE_CODE = "zh-cn"; - public static final String CHINESE_HK_LANGUAGE_CODE = "zh-hk"; - public static final String CHINESE_MO_LANGUAGE_CODE = "zh-mo"; - public static final String CHINESE_SG_LANGUAGE_CODE = "zh-sg"; - public static final String CHINESE_TW_LANGUAGE_CODE = "zh-tw"; - public static final String CHINESE_YUE_LANGUAGE_CODE = "zh-yue"; - public static final String CHINESE_LANGUAGE_CODE = "zh"; - public static final String NORWEGIAN_LEGACY_LANGUAGE_CODE = "no"; - public static final String NORWEGIAN_BOKMAL_LANGUAGE_CODE = "nb"; - public static final String TEST_LANGUAGE_CODE = "test"; - public static final String FALLBACK_LANGUAGE_CODE = "en"; // Must exist in preference_language_keys. - - @NonNull private final Resources resources; - - // Language codes for all app supported languages in fixed order. The special code representing - // the dynamic system language is null. - @NonNull private SoftReference> codesRef = new SoftReference<>(null); - - // English names for all app supported languages in fixed order. - @NonNull private SoftReference> canonicalNamesRef = new SoftReference<>(null); - - // Native names for all app supported languages in fixed order. - @NonNull private SoftReference> localizedNamesRef = new SoftReference<>(null); - - public AppLanguageLookUpTable(@NonNull Context context) { - resources = context.getResources(); - } - - /** - * @return Nonnull immutable list. The special code representing the dynamic system language is - * null. - */ - @NonNull - public List getCodes() { - List codes = codesRef.get(); - if (codes == null) { - codes = getStringList(R.array.preference_language_keys); - codesRef = new SoftReference<>(codes); - } - return codes; - } - - @Nullable - public String getCanonicalName(@Nullable String code) { - String name = defaultIndex(getCanonicalNames(), indexOfCode(code), null); - if (TextUtils.isEmpty(name) && !TextUtils.isEmpty(code)) { - if (code.equals(Locale.CHINESE.getLanguage())) { - name = Locale.CHINESE.getDisplayName(Locale.ENGLISH); - } else if (code.equals(NORWEGIAN_LEGACY_LANGUAGE_CODE)) { - name = defaultIndex(getCanonicalNames(), indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE), null); - } - } - return name; - } - - @Nullable - public String getLocalizedName(@Nullable String code) { - String name = defaultIndex(getLocalizedNames(), indexOfCode(code), null); - if (TextUtils.isEmpty(name) && !TextUtils.isEmpty(code)) { - if (code.equals(Locale.CHINESE.getLanguage())) { - name = Locale.CHINESE.getDisplayName(Locale.CHINESE); - } else if (code.equals(NORWEGIAN_LEGACY_LANGUAGE_CODE)) { - name = defaultIndex(getLocalizedNames(), indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE), null); - } - } - return name; - } - - public List getCanonicalNames() { - List names = canonicalNamesRef.get(); - if (names == null) { - names = getStringList(R.array.preference_language_canonical_names); - canonicalNamesRef = new SoftReference<>(names); - } - return names; - } - - public List getLocalizedNames() { - List names = localizedNamesRef.get(); - if (names == null) { - names = getStringList(R.array.preference_language_local_names); - localizedNamesRef = new SoftReference<>(names); - } - return names; - } - - public boolean isSupportedCode(@Nullable String code) { - return getCodes().contains(code); - } - - private T defaultIndex(List list, int index, T defaultValue) { - return inBounds(list, index) ? list.get(index) : defaultValue; - } - - /** - * Searches #codes for the specified language code and returns the index for use in - * #canonicalNames and #localizedNames. - * - * @param code The language code to search for. The special code representing the dynamic system - * language is null. - * @return The index of the language code or -1 if the code is not supported. - */ - private int indexOfCode(@Nullable String code) { - return getCodes().indexOf(code); - } - - /** @return Nonnull immutable list. */ - @NonNull - private List getStringList(int id) { - return Arrays.asList(getStringArray(id)); - } - - private boolean inBounds(List list, int index) { - return index >= 0 && index < list.size(); - } - - public String[] getStringArray(@ArrayRes int id) { - return resources.getStringArray(id); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.kt b/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.kt new file mode 100644 index 000000000..6809fd79c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.kt @@ -0,0 +1,135 @@ +package fr.free.nrw.commons.language + +import android.content.Context +import android.content.res.Resources +import android.text.TextUtils + +import androidx.annotation.ArrayRes +import fr.free.nrw.commons.R +import java.lang.ref.SoftReference +import java.util.Arrays +import java.util.Locale + + +/** Immutable look up table for all app supported languages. All article languages may not be + * present in this table as it is statically bundled with the app. */ +class AppLanguageLookUpTable(context: Context) { + + companion object { + const val SIMPLIFIED_CHINESE_LANGUAGE_CODE = "zh-hans" + const val TRADITIONAL_CHINESE_LANGUAGE_CODE = "zh-hant" + const val CHINESE_CN_LANGUAGE_CODE = "zh-cn" + const val CHINESE_HK_LANGUAGE_CODE = "zh-hk" + const val CHINESE_MO_LANGUAGE_CODE = "zh-mo" + const val CHINESE_SG_LANGUAGE_CODE = "zh-sg" + const val CHINESE_TW_LANGUAGE_CODE = "zh-tw" + const val CHINESE_YUE_LANGUAGE_CODE = "zh-yue" + const val CHINESE_LANGUAGE_CODE = "zh" + const val NORWEGIAN_LEGACY_LANGUAGE_CODE = "no" + const val NORWEGIAN_BOKMAL_LANGUAGE_CODE = "nb" + const val TEST_LANGUAGE_CODE = "test" + const val FALLBACK_LANGUAGE_CODE = "en" // Must exist in preference_language_keys. + } + + private val resources: Resources = context.resources + + // Language codes for all app supported languages in fixed order. The special code representing + // the dynamic system language is null. + private var codesRef = SoftReference>(null) + + // English names for all app supported languages in fixed order. + private var canonicalNamesRef = SoftReference>(null) + + // Native names for all app supported languages in fixed order. + private var localizedNamesRef = SoftReference>(null) + + /** + * @return Nonnull immutable list. The special code representing the dynamic system language is + * null. + */ + fun getCodes(): List { + var codes = codesRef.get() + if (codes == null) { + codes = getStringList(R.array.preference_language_keys) + codesRef = SoftReference(codes) + } + return codes + } + + fun getCanonicalName(code: String?): String? { + var name = defaultIndex(getCanonicalNames(), indexOfCode(code), null) + if (name.isNullOrEmpty() && !code.isNullOrEmpty()) { + name = when (code) { + Locale.CHINESE.language -> Locale.CHINESE.getDisplayName(Locale.ENGLISH) + NORWEGIAN_LEGACY_LANGUAGE_CODE -> + defaultIndex(getCanonicalNames(), indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE), null) + else -> null + } + } + return name + } + + fun getLocalizedName(code: String?): String? { + var name = defaultIndex(getLocalizedNames(), indexOfCode(code), null) + if (name.isNullOrEmpty() && !code.isNullOrEmpty()) { + name = when (code) { + Locale.CHINESE.language -> Locale.CHINESE.getDisplayName(Locale.CHINESE) + NORWEGIAN_LEGACY_LANGUAGE_CODE -> + defaultIndex(getLocalizedNames(), indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE), null) + else -> null + } + } + return name + } + + fun getCanonicalNames(): List { + var names = canonicalNamesRef.get() + if (names == null) { + names = getStringList(R.array.preference_language_canonical_names) + canonicalNamesRef = SoftReference(names) + } + return names + } + + fun getLocalizedNames(): List { + var names = localizedNamesRef.get() + if (names == null) { + names = getStringList(R.array.preference_language_local_names) + localizedNamesRef = SoftReference(names) + } + return names + } + + fun isSupportedCode(code: String?): Boolean { + return getCodes().contains(code) + } + + private fun defaultIndex(list: List, index: Int, defaultValue: T?): T? { + return if (inBounds(list, index)) list[index] else defaultValue + } + + /** + * Searches #codes for the specified language code and returns the index for use in + * #canonicalNames and #localizedNames. + * + * @param code The language code to search for. The special code representing the dynamic system + * language is null. + * @return The index of the language code or -1 if the code is not supported. + */ + private fun indexOfCode(code: String?): Int { + return getCodes().indexOf(code) + } + + /** @return Nonnull immutable list. */ + private fun getStringList(id: Int): List { + return getStringArray(id).toList() + } + + private fun inBounds(list: List<*>, index: Int): Boolean { + return index in list.indices + } + + fun getStringArray(@ArrayRes id: Int): Array { + return resources.getStringArray(id) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LatLng.java b/app/src/main/java/fr/free/nrw/commons/location/LatLng.java deleted file mode 100644 index 4970fc54f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/location/LatLng.java +++ /dev/null @@ -1,198 +0,0 @@ -package fr.free.nrw.commons.location; - -import android.location.Location; -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; - -/** - * a latitude and longitude point with accuracy information, often of a picture - */ -public class LatLng implements Parcelable { - - private final double latitude; - private final double longitude; - private final float accuracy; - - /** - * Accepts latitude and longitude. - * North and South values are cut off at 90° - * - * @param latitude the latitude - * @param longitude the longitude - * @param accuracy the accuracy - * - * Examples: - * the Statue of Liberty is located at 40.69° N, 74.04° W - * The Statue of Liberty could be constructed as LatLng(40.69, -74.04, 1.0) - * where positive signifies north, east and negative signifies south, west. - */ - public LatLng(double latitude, double longitude, float accuracy) { - if (-180.0D <= longitude && longitude < 180.0D) { - this.longitude = longitude; - } else { - this.longitude = ((longitude - 180.0D) % 360.0D + 360.0D) % 360.0D - 180.0D; - } - this.latitude = Math.max(-90.0D, Math.min(90.0D, latitude)); - this.accuracy = accuracy; - } - /** - * An alternate constructor for this class. - * @param in A parcelable which contains the latitude, longitude, and accuracy - */ - public LatLng(Parcel in) { - latitude = in.readDouble(); - longitude = in.readDouble(); - accuracy = in.readFloat(); - } - - /** - * gets the latitude and longitude of a given non-null location - * @param location the non-null location of the user - * @return LatLng the Latitude and Longitude of a given location - */ - public static LatLng from(@NonNull Location location) { - return new LatLng(location.getLatitude(), location.getLongitude(), location.getAccuracy()); - } - - /** - * creates a hash code for the longitude and longitude - */ - public int hashCode() { - byte var1 = 1; - long var2 = Double.doubleToLongBits(this.latitude); - int var3 = 31 * var1 + (int)(var2 ^ var2 >>> 32); - var2 = Double.doubleToLongBits(this.longitude); - var3 = 31 * var3 + (int)(var2 ^ var2 >>> 32); - return var3; - } - - /** - * checks for equality of two LatLng objects - * @param o the second LatLng object - */ - public boolean equals(Object o) { - if (this == o) { - return true; - } else if (!(o instanceof LatLng)) { - return false; - } else { - LatLng var2 = (LatLng)o; - return Double.doubleToLongBits(this.latitude) == Double.doubleToLongBits(var2.latitude) && Double.doubleToLongBits(this.longitude) == Double.doubleToLongBits(var2.longitude); - } - } - - /** - * returns a string representation of the latitude and longitude - */ - public String toString() { - return "lat/lng: (" + this.latitude + "," + this.longitude + ")"; - } - - /** - * Rounds the float to 4 digits and returns absolute value. - * - * @param coordinate A coordinate value as string. - * @return String of the rounded number. - */ - private String formatCoordinate(double coordinate) { - double roundedNumber = Math.round(coordinate * 10000d) / 10000d; - double absoluteNumber = Math.abs(roundedNumber); - return String.valueOf(absoluteNumber); - } - - /** - * Returns "N" or "S" depending on the latitude. - * - * @return "N" or "S". - */ - private String getNorthSouth() { - if (this.latitude < 0) { - return "S"; - } - - return "N"; - } - - /** - * Returns "E" or "W" depending on the longitude. - * - * @return "E" or "W". - */ - private String getEastWest() { - if (this.longitude >= 0 && this.longitude < 180) { - return "E"; - } - - return "W"; - } - - /** - * Returns a nicely formatted coordinate string. Used e.g. in - * the detail view. - * - * @return The formatted string. - */ - public String getPrettyCoordinateString() { - return formatCoordinate(this.latitude) + " " + this.getNorthSouth() + ", " - + formatCoordinate(this.longitude) + " " + this.getEastWest(); - } - - /** - * Return the location accuracy in meter. - * - * @return float - */ - public float getAccuracy() { - return accuracy; - } - - /** - * Return the longitude in degrees. - * - * @return double - */ - public double getLongitude() { - return longitude; - } - - /** - * Return the latitude in degrees. - * - * @return double - */ - public double getLatitude() { - return latitude; - } - - public Uri getGmmIntentUri() { - return Uri.parse("geo:" + latitude + "," + longitude + "?z=16"); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeDouble(latitude); - dest.writeDouble(longitude); - dest.writeFloat(accuracy); - } - - public static final Creator CREATOR = new Creator() { - @Override - public LatLng createFromParcel(Parcel in) { - return new LatLng(in); - } - - @Override - public LatLng[] newArray(int size) { - return new LatLng[size]; - } - }; -} - diff --git a/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt b/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt new file mode 100644 index 000000000..7dd9a49ce --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt @@ -0,0 +1,157 @@ +package fr.free.nrw.commons.location + +import android.location.Location +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round + + +/** + * A latitude and longitude point with accuracy information, often of a picture. + */ +data class LatLng( + var latitude: Double, + var longitude: Double, + val accuracy: Float +) : Parcelable { + + /** + * Accepts latitude and longitude. + * North and South values are cut off at 90° + * + * Examples: + * the Statue of Liberty is located at 40.69° N, 74.04° W + * The Statue of Liberty could be constructed as LatLng(40.69, -74.04, 1.0) + * where positive signifies north, east and negative signifies south, west. + */ + init { + val adjustedLongitude = when { + longitude in -180.0..180.0 -> longitude + else -> ((longitude - 180.0) % 360.0 + 360.0) % 360.0 - 180.0 + } + latitude = max(-90.0, min(90.0, latitude)) + longitude = adjustedLongitude + } + + /** + * Accepts a non-null [Location] and converts it to a [LatLng]. + */ + companion object { + fun latLongOrNull(latitude: String?, longitude: String?): LatLng? = + if (!latitude.isNullOrBlank() && !longitude.isNullOrBlank()) { + LatLng(latitude.toDouble(), longitude.toDouble(), 0.0f) + } else { + null + } + + /** + * gets the latitude and longitude of a given non-null location + * @param location the non-null location of the user + * @return LatLng the Latitude and Longitude of a given location + */ + @JvmStatic + fun from(location: Location): LatLng { + return LatLng(location.latitude, location.longitude, location.accuracy) + } + + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): LatLng { + return LatLng(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + + /** + * An alternate constructor for this class. + * @param parcel A parcelable which contains the latitude, longitude, and accuracy + */ + private constructor(parcel: Parcel) : this( + latitude = parcel.readDouble(), + longitude = parcel.readDouble(), + accuracy = parcel.readFloat() + ) + + /** + * Creates a hash code for the latitude and longitude. + */ + override fun hashCode(): Int { + var result = 1 + val latitudeBits = latitude.toBits() + result = 31 * result + (latitudeBits xor (latitudeBits ushr 32)).toInt() + val longitudeBits = longitude.toBits() + result = 31 * result + (longitudeBits xor (longitudeBits ushr 32)).toInt() + return result + } + + /** + * Checks for equality of two LatLng objects. + * @param other the second LatLng object + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LatLng) return false + return latitude.toBits() == other.latitude.toBits() && + longitude.toBits() == other.longitude.toBits() + } + + /** + * Returns a string representation of the latitude and longitude. + */ + override fun toString(): String { + return "lat/lng: ($latitude,$longitude)" + } + + /** + * Returns a nicely formatted coordinate string. Used e.g. in + * the detail view. + * + * @return The formatted string. + */ + fun getPrettyCoordinateString(): String { + return "${formatCoordinate(latitude)} ${getNorthSouth()}, " + + "${formatCoordinate(longitude)} ${getEastWest()}" + } + + /** + * Gets a URI for a Google Maps intent at the location. + */ + fun getGmmIntentUri(): Uri { + return Uri.parse("geo:$latitude,$longitude?z=16") + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeDouble(latitude) + parcel.writeDouble(longitude) + parcel.writeFloat(accuracy) + } + + override fun describeContents(): Int = 0 + + private fun formatCoordinate(coordinate: Double): String { + val roundedNumber = round(coordinate * 10000) / 10000 + return abs(roundedNumber).toString() + } + + /** + * Returns "N" or "S" depending on the latitude. + * + * @return "N" or "S". + */ + private fun getNorthSouth(): String = if (latitude < 0) "S" else "N" + + /** + * Returns "E" or "W" depending on the longitude. + * + * @return "E" or "W". + */ + private fun getEastWest(): String = if (longitude in 0.0..179.999) "E" else "W" +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.java b/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.java deleted file mode 100644 index 77e089c9c..000000000 --- a/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.java +++ /dev/null @@ -1,186 +0,0 @@ -package fr.free.nrw.commons.location; - -import android.Manifest; -import android.Manifest.permission; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.provider.Settings; -import android.widget.Toast; -import androidx.core.app.ActivityCompat; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.filepicker.Constants.RequestCodes; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.PermissionUtils; - -/** - * Helper class to handle location permissions. - * - * Location flow for fragments containing a map is as follows: - * Case 1: When location permission has never been asked for or denied before - * Check if permission is already granted or not. - * If not already granted, ask for it (if it isn't denied twice before). - * If now user grants permission, go to Case 3/4, else go to Case 2. - * - * Case 2: When location permission is just asked but has been denied - * Shows a toast to tell the user why location permission is needed. - * Also shows a rationale to the user, on agreeing to which, we go back to Case 1. - * Show current location / nearby pins / nearby images according to the default location. - * - * Case 3: When location permission are already granted, but location services are off - * Asks the user to turn on the location service, using a dialog. - * If the user rejects, checks for the last known location and shows stuff using that location. - * Also displays a toast telling the user why location should be turned on. - * - * Case 4: When location permission has been granted and location services are also on - * Do whatever is required by that particular activity / fragment using current location. - * - */ -public class LocationPermissionsHelper { - - Activity activity; - LocationServiceManager locationManager; - LocationPermissionCallback callback; - - public LocationPermissionsHelper(Activity activity, LocationServiceManager locationManager, - LocationPermissionCallback callback) { - this.activity = activity; - this.locationManager = locationManager; - this.callback = callback; - } - - /** - * Ask for location permission if the user agrees on attaching location with pictures and the - * app does not have the access to location - * - * @param dialogTitleResource Resource id of the title of the dialog - * @param dialogTextResource Resource id of the text of the dialog - */ - public void requestForLocationAccess( - int dialogTitleResource, - int dialogTextResource - ) { - if (checkLocationPermission(activity)) { - callback.onLocationPermissionGranted(); - } else { - if (ActivityCompat.shouldShowRequestPermissionRationale(activity, - permission.ACCESS_FINE_LOCATION)) { - DialogUtil.showAlertDialog(activity, activity.getString(dialogTitleResource), - activity.getString(dialogTextResource), - activity.getString(android.R.string.ok), - activity.getString(android.R.string.cancel), - () -> { - ActivityCompat.requestPermissions(activity, - new String[]{permission.ACCESS_FINE_LOCATION}, 1); - }, - () -> callback.onLocationPermissionDenied( - activity.getString(R.string.upload_map_location_access)), - null, - false); - } else { - ActivityCompat.requestPermissions(activity, - new String[]{permission.ACCESS_FINE_LOCATION}, - RequestCodes.LOCATION); - } - } - } - - /** - * Shows a dialog for user to open the settings page and turn on location services - * - * @param activity Activity object - * @param dialogTextResource int id of the required string resource - */ - public void showLocationOffDialog(Activity activity, int dialogTextResource) { - DialogUtil - .showAlertDialog(activity, - activity.getString(R.string.ask_to_turn_location_on), - activity.getString(dialogTextResource), - activity.getString(R.string.title_app_shortcut_setting), - activity.getString(R.string.cancel), - () -> openLocationSettings(activity), - () -> Toast.makeText(activity, activity.getString(dialogTextResource), - Toast.LENGTH_LONG).show() - ); - } - - /** - * Opens the location access page in settings, for user to turn on location services - * - * @param activity Activtiy object - */ - public void openLocationSettings(Activity activity) { - final Intent intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS); - final PackageManager packageManager = activity.getPackageManager(); - - if (intent.resolveActivity(packageManager) != null) { - activity.startActivity(intent); - } else { - Toast.makeText(activity, R.string.cannot_open_location_settings, Toast.LENGTH_LONG) - .show(); - } - } - - /** - * Shows a dialog for user to open the app's settings page and give location permission - * - * @param activity Activity object - * @param dialogTextResource int id of the required string resource - */ - public void showAppSettingsDialog(Activity activity, int dialogTextResource) { - DialogUtil - .showAlertDialog(activity, activity.getString(R.string.location_permission_title), - activity.getString(dialogTextResource), - activity.getString(R.string.title_app_shortcut_setting), - activity.getString(R.string.cancel), - () -> openAppSettings(activity), - () -> Toast.makeText(activity, activity.getString(dialogTextResource), - Toast.LENGTH_LONG).show() - ); - } - - /** - * Opens detailed settings page of the app for the user to turn on location services - * - * @param activity Activity object - */ - public void openAppSettings(Activity activity) { - Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - Uri uri = Uri.fromParts("package", activity.getPackageName(), null); - intent.setData(uri); - activity.startActivity(intent); - } - - - /** - * Check if apps have access to location even after having individual access - * - * @return Returns true if location services are on and false otherwise - */ - public boolean isLocationAccessToAppsTurnedOn() { - return (locationManager.isNetworkProviderEnabled() - || locationManager.isGPSProviderEnabled()); - } - - /** - * Checks if location permission is already granted or not - * - * @param activity Activity object - * @return Returns true if location permission is granted and false otherwise - */ - public boolean checkLocationPermission(Activity activity) { - return PermissionUtils.hasPermission(activity, - new String[]{Manifest.permission.ACCESS_FINE_LOCATION}); - } - - /** - * Handle onPermissionDenied within individual classes based on the requirements - */ - public interface LocationPermissionCallback { - - void onLocationPermissionDenied(String toastMessage); - - void onLocationPermissionGranted(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.kt b/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.kt new file mode 100644 index 000000000..fefb59adb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.kt @@ -0,0 +1,199 @@ +package fr.free.nrw.commons.location + +import android.Manifest.permission +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.widget.Toast +import androidx.core.app.ActivityCompat +import fr.free.nrw.commons.R +import fr.free.nrw.commons.filepicker.Constants.RequestCodes +import fr.free.nrw.commons.utils.DialogUtil +import fr.free.nrw.commons.utils.PermissionUtils + +/** + * Helper class to handle location permissions. + * + * Location flow for fragments containing a map is as follows: + * Case 1: When location permission has never been asked for or denied before + * Check if permission is already granted or not. + * If not already granted, ask for it (if it isn't denied twice before). + * If now user grants permission, go to Case 3/4, else go to Case 2. + * + * Case 2: When location permission is just asked but has been denied + * Shows a toast to tell the user why location permission is needed. + * Also shows a rationale to the user, on agreeing to which, we go back to Case 1. + * Show current location / nearby pins / nearby images according to the default location. + * + * Case 3: When location permission are already granted, but location services are off + * Asks the user to turn on the location service, using a dialog. + * If the user rejects, checks for the last known location and shows stuff using that location. + * Also displays a toast telling the user why location should be turned on. + * + * Case 4: When location permission has been granted and location services are also on + * Do whatever is required by that particular activity / fragment using current location. + * + */ +class LocationPermissionsHelper( + private val activity: Activity, + private val locationManager: LocationServiceManager, + private val callback: LocationPermissionCallback? +) { + + /** + * Ask for location permission if the user agrees on attaching location with pictures and the + * app does not have the access to location + * + * @param dialogTitleResource Resource id of the title of the dialog + * @param dialogTextResource Resource id of the text of the dialog + */ + fun requestForLocationAccess( + dialogTitleResource: Int, + dialogTextResource: Int + ) { + if (checkLocationPermission(activity)) { + callback?.onLocationPermissionGranted() + } else { + if (ActivityCompat.shouldShowRequestPermissionRationale( + activity, + permission.ACCESS_FINE_LOCATION + ) + ) { + DialogUtil.showAlertDialog( + activity, + activity.getString(dialogTitleResource), + activity.getString(dialogTextResource), + activity.getString(android.R.string.ok), + activity.getString(android.R.string.cancel), + { + ActivityCompat.requestPermissions( + activity, + arrayOf(permission.ACCESS_FINE_LOCATION), + 1 + ) + }, + { + callback?.onLocationPermissionDenied( + activity.getString(R.string.upload_map_location_access) + ) + }, + null + ) + } else { + ActivityCompat.requestPermissions( + activity, + arrayOf(permission.ACCESS_FINE_LOCATION), + RequestCodes.LOCATION + ) + } + } + } + + /** + * Shows a dialog for user to open the settings page and turn on location services + * + * @param activity Activity object + * @param dialogTextResource int id of the required string resource + */ + fun showLocationOffDialog(activity: Activity, dialogTextResource: Int) { + DialogUtil.showAlertDialog( + activity, + activity.getString(R.string.ask_to_turn_location_on), + activity.getString(dialogTextResource), + activity.getString(R.string.title_app_shortcut_setting), + activity.getString(R.string.cancel), + { openLocationSettings(activity) }, + { + Toast.makeText( + activity, + activity.getString(dialogTextResource), + Toast.LENGTH_LONG + ).show() + } + ) + } + + /** + * Opens the location access page in settings, for user to turn on location services + * + * @param activity Activity object + */ + fun openLocationSettings(activity: Activity) { + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + val packageManager = activity.packageManager + + if (intent.resolveActivity(packageManager) != null) { + activity.startActivity(intent) + } else { + Toast.makeText(activity, R.string.cannot_open_location_settings, Toast.LENGTH_LONG) + .show() + } + } + + /** + * Shows a dialog for user to open the app's settings page and give location permission + * + * @param activity Activity object + * @param dialogTextResource int id of the required string resource + */ + fun showAppSettingsDialog(activity: Activity, dialogTextResource: Int) { + DialogUtil.showAlertDialog( + activity, + activity.getString(R.string.location_permission_title), + activity.getString(dialogTextResource), + activity.getString(R.string.title_app_shortcut_setting), + activity.getString(R.string.cancel), + { openAppSettings(activity) }, + { + Toast.makeText( + activity, + activity.getString(dialogTextResource), + Toast.LENGTH_LONG + ).show() + } + ) + } + + /** + * Opens detailed settings page of the app for the user to turn on location services + * + * @param activity Activity object + */ + private fun openAppSettings(activity: Activity) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", activity.packageName, null) + intent.data = uri + activity.startActivity(intent) + } + + /** + * Check if apps have access to location even after having individual access + * + * @return Returns true if location services are on and false otherwise + */ + fun isLocationAccessToAppsTurnedOn(): Boolean { + return locationManager.isNetworkProviderEnabled() || locationManager.isGPSProviderEnabled() + } + + /** + * Checks if location permission is already granted or not + * + * @param activity Activity object + * @return Returns true if location permission is granted and false otherwise + */ + fun checkLocationPermission(activity: Activity): Boolean { + return PermissionUtils.hasPermission( + activity, + arrayOf(permission.ACCESS_FINE_LOCATION) + ) + } + + /** + * Handle onPermissionDenied within individual classes based on the requirements + */ + interface LocationPermissionCallback { + fun onLocationPermissionDenied(toastMessage: String) + fun onLocationPermissionGranted() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.java b/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.java deleted file mode 100644 index 4c7289ea5..000000000 --- a/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.java +++ /dev/null @@ -1,274 +0,0 @@ -package fr.free.nrw.commons.location; - -import android.Manifest.permission; -import android.app.Activity; -import android.content.Context; -import android.content.pm.PackageManager; -import android.location.Location; -import android.location.LocationListener; -import android.location.LocationManager; -import android.os.Bundle; -import androidx.core.app.ActivityCompat; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArrayList; - -import timber.log.Timber; - -public class LocationServiceManager implements LocationListener { - - // Maybe these values can be improved for efficiency - private static final long MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS = 10 * 100; - private static final long MIN_LOCATION_UPDATE_REQUEST_DISTANCE_IN_METERS = 1; - - private LocationManager locationManager; - private Location lastLocation; - //private Location lastLocationDuplicate; // Will be used for nearby card view on contributions activity - private final List locationListeners = new CopyOnWriteArrayList<>(); - private boolean isLocationManagerRegistered = false; - private Set locationExplanationDisplayed = new HashSet<>(); - private Context context; - - /** - * Constructs a new instance of LocationServiceManager. - * - * @param context the context - */ - public LocationServiceManager(Context context) { - this.context = context; - this.locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); - } - - public LatLng getLastLocation() { - if (lastLocation == null) { - lastLocation = getLastKnownLocation(); - if(lastLocation != null) { - return LatLng.from(lastLocation); - } - else { - return null; - } - } - return LatLng.from(lastLocation); - } - - private Location getLastKnownLocation() { - List providers = locationManager.getProviders(true); - Location bestLocation = null; - for (String provider : providers) { - Location l=null; - if (ActivityCompat.checkSelfPermission(context, permission.ACCESS_FINE_LOCATION) - == PackageManager.PERMISSION_GRANTED - && ActivityCompat.checkSelfPermission(context, permission.ACCESS_COARSE_LOCATION) - == PackageManager.PERMISSION_GRANTED) { - l = locationManager.getLastKnownLocation(provider); - } - if (l == null) { - continue; - } - if (bestLocation == null - || l.getAccuracy() < bestLocation.getAccuracy()) { - bestLocation = l; - } - } - if (bestLocation == null) { - return null; - } - return bestLocation; - } - - /** - * Registers a LocationManager to listen for current location. - */ - public void registerLocationManager() { - if (!isLocationManagerRegistered) { - isLocationManagerRegistered = requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER) - && requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER); - } - } - - /** - * Requests location updates from the specified provider. - * - * @param locationProvider the location provider - * @return true if successful - */ - public boolean requestLocationUpdatesFromProvider(String locationProvider) { - try { - // If both providers are not available - if (locationManager == null || !(locationManager.getAllProviders().contains(locationProvider))) { - return false; - } - locationManager.requestLocationUpdates(locationProvider, - MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS, - MIN_LOCATION_UPDATE_REQUEST_DISTANCE_IN_METERS, - this); - return true; - } catch (IllegalArgumentException e) { - Timber.e(e, "Illegal argument exception"); - return false; - } catch (SecurityException e) { - Timber.e(e, "Security exception"); - return false; - } - } - - /** - * Returns whether a given location is better than the current best location. - * - * @param location the location to be tested - * @param currentBestLocation the current best location - * @return LOCATION_SIGNIFICANTLY_CHANGED if location changed significantly - * LOCATION_SLIGHTLY_CHANGED if location changed slightly - */ - private LocationChangeType isBetterLocation(Location location, Location currentBestLocation) { - - if (currentBestLocation == null) { - // A new location is always better than no location - return LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED; - } - - // Check whether the new location fix is newer or older - long timeDelta = location.getTime() - currentBestLocation.getTime(); - boolean isSignificantlyNewer = timeDelta > MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS; - boolean isNewer = timeDelta > 0; - - // Check whether the new location fix is more or less accurate - int accuracyDelta = (int) (location.getAccuracy() - currentBestLocation.getAccuracy()); - boolean isLessAccurate = accuracyDelta > 0; - boolean isMoreAccurate = accuracyDelta < 0; - boolean isSignificantlyLessAccurate = accuracyDelta > 200; - - // Check if the old and new location are from the same provider - boolean isFromSameProvider = isSameProvider(location.getProvider(), - currentBestLocation.getProvider()); - - float[] results = new float[5]; - Location.distanceBetween( - currentBestLocation.getLatitude(), - currentBestLocation.getLongitude(), - location.getLatitude(), - location.getLongitude(), - results); - - // If it's been more than two minutes since the current location, use the new location - // because the user has likely moved - if (isSignificantlyNewer - || isMoreAccurate - || (isNewer && !isLessAccurate) - || (isNewer && !isSignificantlyLessAccurate && isFromSameProvider)) { - if (results[0] < 1000) { // Means change is smaller than 1000 meter - return LocationChangeType.LOCATION_SLIGHTLY_CHANGED; - } else { - return LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED; - } - } else{ - return LocationChangeType.LOCATION_NOT_CHANGED; - } - } - - /** - * Checks whether two providers are the same - */ - private boolean isSameProvider(String provider1, String provider2) { - if (provider1 == null) { - return provider2 == null; - } - return provider1.equals(provider2); - } - - /** - * Unregisters location manager. - */ - public void unregisterLocationManager() { - isLocationManagerRegistered = false; - locationExplanationDisplayed.clear(); - try { - locationManager.removeUpdates(this); - } catch (SecurityException e) { - Timber.e(e, "Security exception"); - } - } - - /** - * Adds a new listener to the list of location listeners. - * - * @param listener the new listener - */ - public void addLocationListener(LocationUpdateListener listener) { - if (!locationListeners.contains(listener)) { - locationListeners.add(listener); - } - } - - /** - * Removes a listener from the list of location listeners. - * - * @param listener the listener to be removed - */ - public void removeLocationListener(LocationUpdateListener listener) { - locationListeners.remove(listener); - } - - @Override - public void onLocationChanged(Location location) { - Timber.d("on location changed"); - if (isBetterLocation(location, lastLocation) - .equals(LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED)) { - lastLocation = location; - //lastLocationDuplicate = location; - for (LocationUpdateListener listener : locationListeners) { - listener.onLocationChangedSignificantly(LatLng.from(lastLocation)); - } - } else if (location.distanceTo(lastLocation) >= 500) { - // Update nearby notification card at every 500 meters. - for (LocationUpdateListener listener : locationListeners) { - listener.onLocationChangedMedium(LatLng.from(lastLocation)); - } - } - - else if (isBetterLocation(location, lastLocation) - .equals(LocationChangeType.LOCATION_SLIGHTLY_CHANGED)) { - lastLocation = location; - //lastLocationDuplicate = location; - for (LocationUpdateListener listener : locationListeners) { - listener.onLocationChangedSlightly(LatLng.from(lastLocation)); - } - } - } - - @Override - public void onStatusChanged(String provider, int status, Bundle extras) { - Timber.d("%s's status changed to %d", provider, status); - } - - @Override - public void onProviderEnabled(String provider) { - Timber.d("Provider %s enabled", provider); - } - - @Override - public void onProviderDisabled(String provider) { - Timber.d("Provider %s disabled", provider); - } - - public boolean isNetworkProviderEnabled() { - return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER); - } - - public boolean isGPSProviderEnabled() { - return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); - } - - public enum LocationChangeType{ - LOCATION_SIGNIFICANTLY_CHANGED, //Went out of borders of nearby markers - LOCATION_SLIGHTLY_CHANGED, //User might be walking or driving - LOCATION_MEDIUM_CHANGED, //Between slight and significant changes, will be used for nearby card view updates. - LOCATION_NOT_CHANGED, - PERMISSION_JUST_GRANTED, - MAP_UPDATED, - SEARCH_CUSTOM_AREA, - CUSTOM_QUERY - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.kt b/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.kt new file mode 100644 index 000000000..3a4c4b72e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.kt @@ -0,0 +1,255 @@ +package fr.free.nrw.commons.location + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import androidx.core.app.ActivityCompat +import timber.log.Timber +import java.util.concurrent.CopyOnWriteArrayList + + +class LocationServiceManager(private val context: Context) : LocationListener { + + companion object { + // Maybe these values can be improved for efficiency + private const val MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS = 10 * 100L + private const val MIN_LOCATION_UPDATE_REQUEST_DISTANCE_IN_METERS = 1f + } + + private val locationManager: LocationManager = + context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + private var lastLocationVar: Location? = null + private val locationListeners = CopyOnWriteArrayList() + private var isLocationManagerRegistered = false + private val locationExplanationDisplayed = mutableSetOf() + + /** + * Constructs a new instance of LocationServiceManager. + * + */ + fun getLastLocation(): LatLng? { + if (lastLocationVar == null) { + lastLocationVar = getLastKnownLocation() + return lastLocationVar?.let { LatLng.from(it) } + } + return LatLng.from(lastLocationVar!!) + } + + private fun getLastKnownLocation(): Location? { + val providers = locationManager.getProviders(true) + var bestLocation: Location? = null + for (provider in providers) { + val location: Location? = if ( + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION) + == + PackageManager.PERMISSION_GRANTED && + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION) + == + PackageManager.PERMISSION_GRANTED + ) { + locationManager.getLastKnownLocation(provider) + } else { + null + } + + if ( + location != null + && + (bestLocation == null || location.accuracy < bestLocation.accuracy) + ) { + bestLocation = location + } + } + return bestLocation + } + + /** + * Registers a LocationManager to listen for current location. + */ + fun registerLocationManager() { + if (!isLocationManagerRegistered) { + isLocationManagerRegistered = + requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER) && + requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER) + } + } + + /** + * Requests location updates from the specified provider. + * + * @param locationProvider the location provider + * @return true if successful + */ + fun requestLocationUpdatesFromProvider(locationProvider: String): Boolean { + return try { + if (locationManager.allProviders.contains(locationProvider)) { + locationManager.requestLocationUpdates( + locationProvider, + MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS, + MIN_LOCATION_UPDATE_REQUEST_DISTANCE_IN_METERS, + this + ) + true + } else { + false + } + } catch (e: IllegalArgumentException) { + Timber.e(e, "Illegal argument exception") + false + } catch (e: SecurityException) { + Timber.e(e, "Security exception") + false + } + } + + /** + * Returns whether a given location is better than the current best location. + * + * @param location the location to be tested + * @param currentBestLocation the current best location + * @return LOCATION_SIGNIFICANTLY_CHANGED if location changed significantly + * LOCATION_SLIGHTLY_CHANGED if location changed slightly + */ + private fun isBetterLocation(location: Location, currentBestLocation: Location?): LocationChangeType { + if (currentBestLocation == null) { + return LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED + } + + val timeDelta = location.time - currentBestLocation.time + val isSignificantlyNewer = timeDelta > MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS + val isNewer = timeDelta > 0 + val accuracyDelta = (location.accuracy - currentBestLocation.accuracy).toInt() + val isMoreAccurate = accuracyDelta < 0 + val isSignificantlyLessAccurate = accuracyDelta > 200 + val isFromSameProvider = isSameProvider(location.provider, currentBestLocation.provider) + + val results = FloatArray(5) + Location.distanceBetween( + currentBestLocation.latitude, currentBestLocation.longitude, + location.latitude, location.longitude, + results + ) + + return when { + isSignificantlyNewer + || + isMoreAccurate + || + (isNewer && !isSignificantlyLessAccurate && isFromSameProvider) -> { + if (results[0] < 1000) LocationChangeType.LOCATION_SLIGHTLY_CHANGED + else LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED + } + else -> LocationChangeType.LOCATION_NOT_CHANGED + } + } + + /** + * Checks whether two providers are the same + */ + private fun isSameProvider(provider1: String?, provider2: String?): Boolean { + return provider1 == provider2 + } + + /** + * Unregisters location manager. + */ + fun unregisterLocationManager() { + isLocationManagerRegistered = false + locationExplanationDisplayed.clear() + try { + locationManager.removeUpdates(this) + } catch (e: SecurityException) { + Timber.e(e, "Security exception") + } + } + + /** + * Adds a new listener to the list of location listeners. + * + * @param listener the new listener + */ + fun addLocationListener(listener: LocationUpdateListener) { + if (!locationListeners.contains(listener)) { + locationListeners.add(listener) + } + } + + /** + * Removes a listener from the list of location listeners. + * + * @param listener the listener to be removed + */ + fun removeLocationListener(listener: LocationUpdateListener) { + locationListeners.remove(listener) + } + + override fun onLocationChanged(location: Location) { + Timber.d("on location changed") + val changeType = isBetterLocation(location, lastLocationVar) + if (changeType == LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED) { + lastLocationVar = location + locationListeners.forEach { it.onLocationChangedSignificantly(LatLng.from(location)) } + } else if (lastLocationVar?.let { location.distanceTo(it) }!! >= 500) { + locationListeners.forEach { it.onLocationChangedMedium(LatLng.from(location)) } + } else if (changeType == LocationChangeType.LOCATION_SLIGHTLY_CHANGED) { + lastLocationVar = location + locationListeners.forEach { it.onLocationChangedSlightly(LatLng.from(location)) } + } + } + + @Deprecated("Deprecated in Java", ReplaceWith( + "Timber.d(\"%s's status changed to %d\", provider, status)", + "timber.log.Timber" + ) + ) + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + Timber.d("%s's status changed to %d", provider, status) + } + + + + override fun onProviderEnabled(provider: String) { + Timber.d("Provider %s enabled", provider) + } + + override fun onProviderDisabled(provider: String) { + Timber.d("Provider %s disabled", provider) + } + + fun isNetworkProviderEnabled(): Boolean { + return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } + + fun isGPSProviderEnabled(): Boolean { + return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + } + + enum class LocationChangeType { + LOCATION_SIGNIFICANTLY_CHANGED, + LOCATION_SLIGHTLY_CHANGED, + LOCATION_MEDIUM_CHANGED, + LOCATION_NOT_CHANGED, + PERMISSION_JUST_GRANTED, + MAP_UPDATED, + SEARCH_CUSTOM_AREA, + CUSTOM_QUERY + } +} + + + + + + + + + diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.java b/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.java deleted file mode 100644 index 61ff26b11..000000000 --- a/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.location; - -public interface LocationUpdateListener { - void onLocationChangedSignificantly(LatLng latLng); // Will be used to update all nearby markers on the map - void onLocationChangedSlightly(LatLng latLng); // Will be used to track users motion - void onLocationChangedMedium(LatLng latLng); // Will be used updating nearby card view notification -} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.kt b/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.kt new file mode 100644 index 000000000..e90cc1224 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.location + +interface LocationUpdateListener { + // Will be used to update all nearby markers on the map + fun onLocationChangedSignificantly(latLng: LatLng) + + // Will be used to track users motion + fun onLocationChangedSlightly(latLng: LatLng) + + // Will be used updating nearby card view notification + fun onLocationChangedMedium(latLng: LatLng) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPicker.kt b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPicker.kt new file mode 100644 index 000000000..7121d3a30 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPicker.kt @@ -0,0 +1,72 @@ +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 + */ +object LocationPicker { + + /** + * Getting camera position from the intent using constants + * + * @param data intent + * @return CameraPosition + */ + @JvmStatic + fun getCameraPosition(data: Intent): CameraPosition? { + return data.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION) + } + + class IntentBuilder + /** + * Creates a new builder that creates an intent to launch the place picker activity. + */() { + + private val intent: Intent = Intent() + + /** + * Gets and puts location in intent + * @param position CameraPosition + * @return locationpicker.IntentBuilder + */ + fun defaultLocation(position: CameraPosition): IntentBuilder { + intent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, position) + return this + } + + /** + * Gets and puts activity name in intent + * @param activity activity key + * @return locationpicker.IntentBuilder + */ + fun activityKey(activity: String): IntentBuilder { + intent.putExtra(LocationPickerConstants.ACTIVITY_KEY, activity) + return this + } + + /** + * Gets and puts media in intent + * @param media Media + * @return locationpicker.IntentBuilder + */ + fun media(media: Media): IntentBuilder { + intent.putExtra(LocationPickerConstants.MEDIA, media) + return this + } + + /** + * Gets and sets the activity + * @param activity Activity + * @return Intent + */ + fun build(activity: Activity): Intent { + intent.setClass(activity, LocationPickerActivity::class.java) + return intent + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt new file mode 100644 index 000000000..69e4eee64 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt @@ -0,0 +1,726 @@ +package fr.free.nrw.commons.locationpicker + +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.location.LocationManager +import android.os.Bundle +import androidx.preference.PreferenceManager +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.appcompat.widget.AppCompatTextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import androidx.core.os.BundleCompat +import androidx.core.text.HtmlCompat +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.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION +import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM +import fr.free.nrw.commons.utils.DialogUtil +import fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +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.ScaleDiskOverlay +import org.osmdroid.views.overlay.TilesOverlay +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named + + +/** + * Helps to pick location and return the result with an intent + */ +class LocationPickerActivity : BaseActivity(), LocationPermissionCallback { + /** + * coordinateEditHelper: helps to edit coordinates + */ + @Inject + lateinit var coordinateEditHelper: CoordinateEditHelper + + /** + * media : Media object + */ + private var media: Media? = null + + /** + * cameraPosition : position of picker + */ + private var cameraPosition: CameraPosition? = null + + /** + * markerImage : picker image + */ + private lateinit var markerImage: ImageView + + /** + * mapView : OSM Map + */ + private var mapView: org.osmdroid.views.MapView? = null + + /** + * tvAttribution : credit + */ + private lateinit var tvAttribution: AppCompatTextView + + /** + * activity : activity key + */ + private var activity: String? = null + + /** + * modifyLocationButton : button for start editing location + */ + private lateinit var modifyLocationButton: Button + + /** + * removeLocationButton : button to remove location metadata + */ + private lateinit var removeLocationButton: Button + + /** + * showInMapButton : button for showing in map + */ + private lateinit var showInMapButton: TextView + + /** + * placeSelectedButton : fab for selecting location + */ + private lateinit var placeSelectedButton: FloatingActionButton + + /** + * fabCenterOnLocation: button for center on location; + */ + private lateinit var fabCenterOnLocation: FloatingActionButton + + /** + * shadow : imageview of shadow + */ + private lateinit var shadow: ImageView + + /** + * largeToolbarText : textView of shadow + */ + private lateinit var largeToolbarText: TextView + + /** + * smallToolbarText : textView of shadow + */ + private lateinit var smallToolbarText: TextView + + /** + * applicationKvStore : for storing values + */ + @Inject + @field: Named("default_preferences") + lateinit var applicationKvStore: JsonKvStore + private lateinit var store: BasicKvStore + + /** + * isDarkTheme: for keeping a track of the device theme and modifying the map theme accordingly + */ + private var isDarkTheme: Boolean = false + private var moveToCurrentLocation: Boolean = false + + @Inject + lateinit var locationManager: LocationServiceManager + private lateinit var locationPermissionsHelper: LocationPermissionsHelper + + @Inject + lateinit var sessionManager: SessionManager + + /** + * Constants + */ + companion object { + private const val CAMERA_POS = "cameraPosition" + private const val ACTIVITY = "activity" + } + + @SuppressLint("ClickableViewAccessibility") + override fun onCreate(savedInstanceState: Bundle?) { + requestWindowFeature(Window.FEATURE_ACTION_BAR) + super.onCreate(savedInstanceState) + + isDarkTheme = systemThemeUtils.isDeviceInNightMode() + moveToCurrentLocation = false + store = BasicKvStore(this, "LocationPermissions") + + requestWindowFeature(Window.FEATURE_ACTION_BAR) + supportActionBar?.hide() + setContentView(R.layout.activity_location_picker) + + if (savedInstanceState == null) { + cameraPosition = IntentCompat.getParcelableExtra( + intent, + LocationPickerConstants.MAP_CAMERA_POSITION, + CameraPosition::class.java + ) + activity = intent.getStringExtra(LocationPickerConstants.ACTIVITY_KEY) + media = IntentCompat.getParcelableExtra( + intent, + LocationPickerConstants.MEDIA, + Media::class.java + ) + } else { + cameraPosition = BundleCompat.getParcelable( + savedInstanceState, + CAMERA_POS, + CameraPosition::class.java + ) + activity = savedInstanceState.getString(ACTIVITY) + media = BundleCompat.getParcelable(savedInstanceState, "sMedia", Media::class.java) + } + + bindViews() + addBackButtonListener() + addPlaceSelectedButton() + addCredits() + getToolbarUI() + addCenterOnGPSButton() + + org.osmdroid.config.Configuration.getInstance() + .load( + applicationContext, PreferenceManager.getDefaultSharedPreferences( + applicationContext + ) + ) + + mapView?.setTileSource(TileSourceFactory.WIKIMEDIA) + mapView?.setTilesScaledToDpi(true) + mapView?.setMultiTouchControls(true) + + org.osmdroid.config.Configuration.getInstance().additionalHttpRequestProperties["Referer"] = + "http://maps.wikimedia.org/" + mapView?.zoomController?.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + mapView?.controller?.setZoom(ZOOM_LEVEL.toDouble()) + mapView?.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_MOVE -> { + if (markerImage.translationY == 0f) { + markerImage.animate().translationY(-75f) + .setInterpolator(OvershootInterpolator()).duration = 250 + } + } + MotionEvent.ACTION_UP -> { + markerImage.animate().translationY(0f) + .setInterpolator(OvershootInterpolator()).duration = 250 + } + } + false + } + + if (activity == "UploadActivity") { + placeSelectedButton.visibility = View.GONE + modifyLocationButton.visibility = View.VISIBLE + removeLocationButton.visibility = View.VISIBLE + showInMapButton.visibility = View.VISIBLE + largeToolbarText.text = getString(R.string.image_location) + smallToolbarText.text = getString(R.string.check_whether_location_is_correct) + fabCenterOnLocation.visibility = View.GONE + markerImage.visibility = View.GONE + shadow.visibility = View.GONE + cameraPosition?.let { + showSelectedLocationMarker(GeoPoint(it.latitude, it.longitude)) + } + } + setupMapView() + } + + /** + * Moves the center of the map to the specified coordinates + */ + private fun moveMapTo(latitude: Double, longitude: Double) { + mapView?.controller?.let { + val point = GeoPoint(latitude, longitude) + it.setCenter(point) + it.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 fun moveMapTo(point: GeoPoint?) { + point?.let { + moveMapTo(it.latitude, it.longitude) + } + } + + /** + * For showing credits + */ + private fun addCredits() { + tvAttribution.text = HtmlCompat.fromHtml( + getString(R.string.map_attribution), + HtmlCompat.FROM_HTML_MODE_LEGACY + ) + tvAttribution.movementMethod = LinkMovementMethod.getInstance() + } + + /** + * For setting up Dark Theme + */ + private fun darkThemeSetup() { + if (isDarkTheme) { + shadow.setColorFilter(Color.argb(255, 255, 255, 255)) + mapView?.overlayManager?.tilesOverlay?.setColorFilter(TilesOverlay.INVERT_COLORS) + } + } + + /** + * Clicking back button destroy locationPickerActivity + */ + private fun addBackButtonListener() { + val backButton = findViewById(R.id.maplibre_place_picker_toolbar_back_button) + backButton.setOnClickListener { + finish() + } + } + + /** + * Binds mapView and location picker icon + */ + private fun 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.text = getString(R.string.show_in_map_app).uppercase(Locale.ROOT) + shadow = findViewById(R.id.location_picker_image_view_shadow) + } + + /** + * Gets toolbar color + */ + private fun getToolbarUI() { + val toolbar: ConstraintLayout = 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(ContextCompat.getColor(this, R.color.primaryColor)) + } + + private fun setupMapView() { + requestLocationPermissions() + + //If location metadata is available, move map to that location. + if (activity == "UploadActivity" || activity == "MediaActivity") { + moveMapToMediaLocation() + } else { + //If location metadata is not available, move map to device GPS location. + moveMapToGPSLocation() + } + + modifyLocationButton.setOnClickListener { onClickModifyLocation() } + removeLocationButton.setOnClickListener { onClickRemoveLocation() } + showInMapButton.setOnClickListener { showInMapApp() } + darkThemeSetup() + } + + /** + * Handles onClick event of modifyLocationButton + */ + private fun onClickModifyLocation() { + placeSelectedButton.visibility = View.VISIBLE + modifyLocationButton.visibility = View.GONE + removeLocationButton.visibility = View.GONE + showInMapButton.visibility = View.GONE + markerImage.visibility = View.VISIBLE + shadow.visibility = View.VISIBLE + largeToolbarText.text = getString(R.string.choose_a_location) + smallToolbarText.text = getString(R.string.pan_and_zoom_to_adjust) + fabCenterOnLocation.visibility = View.VISIBLE + removeSelectedLocationMarker() + moveMapToMediaLocation() + } + + /** + * Handles onClick event of removeLocationButton + */ + private fun 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 + ) + } + + /** + * Removes location metadata from the image + */ + private fun removeLocationFromImage() { + media?.let { + coordinateEditHelper.makeCoordinatesEdit( + applicationContext, it, "0.0", "0.0", "0.0f" + ) + ?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe { _ -> + Timber.d("Coordinates removed from the image") + }?.let { it1 -> + compositeDisposable.add( + it1 + ) + } + } + setResult(RESULT_OK, Intent()) + finish() + } + + /** + * Show location in map app + */ + private fun showInMapApp() { + val position = when { + //location metadata is available + activity == "UploadActivity" && cameraPosition != null -> { + fr.free.nrw.commons.location.LatLng( + cameraPosition!!.latitude, + cameraPosition!!.longitude, + 0.0f + ) + } + //location metadata is not available + mapView != null -> { + fr.free.nrw.commons.location.LatLng( + mapView?.mapCenter?.latitude!!, + mapView?.mapCenter?.longitude!!, + 0.0f + ) + } + else -> null + } + + position?.let { Utils.handleGeoCoordinates(this, it) } + } + + /** + * Moves map to media's location + */ + private fun moveMapToMediaLocation() { + cameraPosition?.let { + moveMapTo(GeoPoint(it.latitude, it.longitude)) + } + } + + /** + * Moves map to GPS location + */ + private fun moveMapToGPSLocation() { + locationManager.getLastLocation()?.let { + moveMapTo(GeoPoint(it.latitude, it.longitude)) + } + } + + /** + * Adds "Place Selected" button + */ + private fun addPlaceSelectedButton() { + placeSelectedButton = findViewById(R.id.location_chosen_button) + placeSelectedButton.setOnClickListener { placeSelected() } + } + + /** + * Handles "Place Selected" action + */ + private fun placeSelected() { + if (activity == "NoLocationUploadActivity") { + applicationKvStore.putString( + LAST_LOCATION, + "${mapView?.mapCenter?.latitude},${mapView?.mapCenter?.longitude}" + ) + applicationKvStore.putString(LAST_ZOOM, mapView?.zoomLevelDouble?.toString()!!) + } + + if (media == null) { + val intent = Intent().apply { + putExtra( + LocationPickerConstants.MAP_CAMERA_POSITION, + CameraPosition( + mapView?.mapCenter?.latitude!!, + mapView?.mapCenter?.longitude!!, + 14.0 + ) + ) + } + setResult(RESULT_OK, intent) + } else { + updateCoordinates( + mapView?.mapCenter?.latitude.toString(), + mapView?.mapCenter?.longitude.toString(), + "0.0f" + ) + } + + finish() + } + + /** + * Updates image with new coordinates + */ + fun updateCoordinates(latitude: String, longitude: String, accuracy: String) { + media?.let { + try { + coordinateEditHelper.makeCoordinatesEdit( + applicationContext, + it, + latitude, + longitude, + accuracy + )?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe { _ -> + Timber.d("Coordinates updated") + }?.let { it1 -> + compositeDisposable.add( + it1 + ) + } + } catch (e: Exception) { + if (e.localizedMessage == CsrfTokenClient.ANONYMOUS_TOKEN_MESSAGE) { + val username = sessionManager.userName + CommonsApplication.BaseLogoutListener( + this, + getString(R.string.invalid_login_message) + , username + ).let { + CommonsApplication.instance.clearApplicationData(this, it) + } + } else { } + } + } + } + + /** + * Adds a button to center the map at user's location + */ + private fun addCenterOnGPSButton() { + fabCenterOnLocation = findViewById(R.id.center_on_gps) + fabCenterOnLocation.setOnClickListener { + moveToCurrentLocation = true + requestLocationPermissions() + } + } + + /** + * Shows a selected location marker + */ + private fun showSelectedLocationMarker(point: GeoPoint) { + val icon = ContextCompat.getDrawable(this, R.drawable.map_default_map_marker) + Marker(mapView).apply { + position = point + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + setIcon(icon) + infoWindow = null + mapView?.overlays?.add(this) + } + mapView?.invalidate() + } + + /** + * Removes selected location marker + */ + private fun removeSelectedLocationMarker() { + val overlays = mapView?.overlays + overlays?.filterIsInstance()?.firstOrNull { + it.position.latitude == + cameraPosition?.latitude && it.position.longitude == cameraPosition?.longitude + }?.let { + overlays.remove(it) + mapView?.invalidate() + } + } + + /** + * Centers map at user's location + */ + private fun requestLocationPermissions() { + locationPermissionsHelper = LocationPermissionsHelper(this, locationManager, this) + locationPermissionsHelper.requestForLocationAccess( + R.string.location_permission_title, + R.string.upload_map_location_access + ) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + if (requestCode == Constants.RequestCodes.LOCATION && + grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + ) { + onLocationPermissionGranted() + } else { + onLocationPermissionDenied(getString(R.string.upload_map_location_access)) + } + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + override fun onResume() { + super.onResume() + mapView?.onResume() + } + + override fun onPause() { + super.onPause() + mapView?.onPause() + } + + override fun onLocationPermissionDenied(toastMessage: String) { + val isDeniedBefore = store.getBoolean("isPermissionDenied", false) + val showRationale = ActivityCompat.shouldShowRequestPermissionRationale( + this, + permission.ACCESS_FINE_LOCATION + ) + + if (!showRationale) { + if (!locationPermissionsHelper.checkLocationPermission(this)) { + if (isDeniedBefore) { + locationPermissionsHelper.showAppSettingsDialog( + this, + R.string.upload_map_location_access + ) + } else { + Toast.makeText(this, toastMessage, Toast.LENGTH_LONG).show() + } + store.putBoolean("isPermissionDenied", true) + } + } else { + Toast.makeText(this, toastMessage, Toast.LENGTH_LONG).show() + } + } + + override fun onLocationPermissionGranted() { + if (moveToCurrentLocation || activity != "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 at the user's GPS location + */ + private fun addMarkerAtGPSLocation() { + locationManager.getLastLocation()?.let { + addLocationMarker(GeoPoint(it.latitude, it.longitude)) + markerImage.translationY = 0f + } + } + + private fun addLocationMarker(geoPoint: GeoPoint) { + if (moveToCurrentLocation) { + mapView?.overlays?.clear() + } + + val diskOverlay = ScaleDiskOverlay( + this, + geoPoint, + 2000, + GeoConstants.UnitOfMeasure.foot + ) + + val circlePaint = Paint().apply { + color = Color.rgb(128, 128, 128) + style = Paint.Style.STROKE + strokeWidth = 2f + } + diskOverlay.setCirclePaint2(circlePaint) + + val diskPaint = Paint().apply { + color = Color.argb(40, 128, 128, 128) + style = Paint.Style.FILL_AND_STROKE + } + diskOverlay.setCirclePaint1(diskPaint) + + diskOverlay.setDisplaySizeMin(900) + diskOverlay.setDisplaySizeMax(1700) + + mapView?.overlays?.add(diskOverlay) + + val startMarker = Marker(mapView).apply { + position = geoPoint + setAnchor( + Marker.ANCHOR_CENTER, + Marker.ANCHOR_BOTTOM + ) + icon = ContextCompat.getDrawable( + this@LocationPickerActivity, + R.drawable.current_location_marker + ) + title = "Your Location" + textLabelFontSize = 24 + } + + mapView?.overlays?.add(startMarker) + } + + /** + * Saves the state of the activity + * @param outState Bundle + */ + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + cameraPosition?.let { + outState.putParcelable(CAMERA_POS, it) + } + + activity?.let { + outState.putString(ACTIVITY, it) + } + + media?.let { + outState.putParcelable("sMedia", it) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerConstants.kt b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerConstants.kt new file mode 100644 index 000000000..edc76ce64 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerConstants.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.locationpicker + +/** + * Constants need for location picking + */ +object LocationPickerConstants { + + const val ACTIVITY_KEY = "location.picker.activity" + + const val MAP_CAMERA_POSITION = "location.picker.cameraPosition" + + const val MEDIA = "location.picker.media" +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerViewModel.kt b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerViewModel.kt new file mode 100644 index 000000000..044643ede --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerViewModel.kt @@ -0,0 +1,44 @@ +package fr.free.nrw.commons.locationpicker + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import fr.free.nrw.commons.CameraPosition +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import timber.log.Timber + +/** + * Observes live camera position data + */ +class LocationPickerViewModel( + application: Application +): AndroidViewModel(application), Callback { + + /** + * Wrapping CameraPosition with MutableLiveData + */ + val result = MutableLiveData() + + /** + * Responses on camera position changing + * + * @param call Call + * @param response Response + */ + override fun onResponse( + call: Call, + response: Response + ) { + if(response.body() == null) { + result.value = null + return + } + result.value = response.body() + } + + override fun onFailure(call: Call, t: Throwable) { + Timber.e(t) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.java b/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.java deleted file mode 100644 index 29c2c732e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.java +++ /dev/null @@ -1,105 +0,0 @@ -package fr.free.nrw.commons.logging; - -import android.content.Context; - -import android.os.Bundle; -import javax.inject.Inject; -import javax.inject.Singleton; - -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.DeviceInfoUtil; -import org.acra.data.CrashReportData; -import org.acra.sender.ReportSenderException; -import org.jetbrains.annotations.NotNull; - -/** - * Class responsible for sending logs to developers - */ -@Singleton -public class CommonsLogSender extends LogsSender { - private static final String LOGS_PRIVATE_EMAIL = "commons-app-android-private@googlegroups.com"; - private static final String LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Android App (%s) Logs"; - private static final String BETA_LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Beta Android App (%s) Logs"; - - private SessionManager sessionManager; - private Context context; - - @Inject - public CommonsLogSender(SessionManager sessionManager, - Context context) { - super(sessionManager); - - this.sessionManager = sessionManager; - this.context = context; - boolean isBeta = ConfigUtils.isBetaFlavour(); - this.logFileName = isBeta ? "CommonsBetaAppLogs.zip" : "CommonsAppLogs.zip"; - String emailSubjectFormat = isBeta ? BETA_LOGS_PRIVATE_EMAIL_SUBJECT : LOGS_PRIVATE_EMAIL_SUBJECT; - this.emailSubject = String.format(emailSubjectFormat, sessionManager.getUserName()); - this.emailBody = getExtraInfo(); - this.mailTo = LOGS_PRIVATE_EMAIL; - } - - /** - * Attach any extra meta information about user or device that might help in debugging - * @return String with extra meta information useful for debugging - */ - @Override - public String getExtraInfo() { - StringBuilder builder = new StringBuilder(); - - // Getting API Level - builder.append("API level: ") - .append(DeviceInfoUtil.getAPILevel()) - .append("\n"); - - // Getting Android Version - builder.append("Android version: ") - .append(DeviceInfoUtil.getAndroidVersion()) - .append("\n"); - - // Getting Device Manufacturer - builder.append("Device manufacturer: ") - .append(DeviceInfoUtil.getDeviceManufacturer()) - .append("\n"); - - // Getting Device Model - builder.append("Device model: ") - .append(DeviceInfoUtil.getDeviceModel()) - .append("\n"); - - // Getting Device Name - builder.append("Device: ") - .append(DeviceInfoUtil.getDevice()) - .append("\n"); - - // Getting Network Type - builder.append("Network type: ") - .append(DeviceInfoUtil.getConnectionType(context)) - .append("\n"); - - // Getting App Version - builder.append("App version name: ") - .append(ConfigUtils.getVersionNameWithSha(context)) - .append("\n"); - - // Getting Username - builder.append("User name: ") - .append(sessionManager.getUserName()) - .append("\n"); - - - return builder.toString(); - } - - @Override - public boolean requiresForeground() { - return false; - } - - @Override - public void send(@NotNull Context context, @NotNull CrashReportData crashReportData, - @NotNull Bundle bundle) throws ReportSenderException { - - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.kt b/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.kt new file mode 100644 index 000000000..7c6b988a6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.kt @@ -0,0 +1,107 @@ +package fr.free.nrw.commons.logging + +import android.content.Context + +import android.os.Bundle +import javax.inject.Inject +import javax.inject.Singleton + +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.utils.ConfigUtils +import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha +import fr.free.nrw.commons.utils.DeviceInfoUtil +import org.acra.data.CrashReportData + + +/** + * Class responsible for sending logs to developers + */ +@Singleton +class CommonsLogSender @Inject constructor( + private val sessionManager: SessionManager, + private val context: Context +) : LogsSender(sessionManager) { + + + companion object { + private const val LOGS_PRIVATE_EMAIL = "commons-app-android-private@googlegroups.com" + private const val LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Android App (%s) Logs" + private const val BETA_LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Beta Android App (%s) Logs" + } + + init { + val isBeta = ConfigUtils.isBetaFlavour + logFileName = if (isBeta) "CommonsBetaAppLogs.zip" else "CommonsAppLogs.zip" + val emailSubjectFormat = if (isBeta) + BETA_LOGS_PRIVATE_EMAIL_SUBJECT + else + LOGS_PRIVATE_EMAIL_SUBJECT + emailSubject = emailSubjectFormat.format(sessionManager.userName) + emailBody = getExtraInfo() + mailTo = LOGS_PRIVATE_EMAIL + } + + /** + * Attach any extra meta information about the user or device that might help in debugging. + * @return String with extra meta information useful for debugging. + */ + public override fun getExtraInfo(): String { + return buildString { + // Getting API Level + append("API level: ") + .append(DeviceInfoUtil.getAPILevel()) + .append("\n") + + // Getting Android Version + append("Android version: ") + .append(DeviceInfoUtil.getAndroidVersion()) + .append("\n") + + // Getting Device Manufacturer + append("Device manufacturer: ") + .append(DeviceInfoUtil.getDeviceManufacturer()) + .append("\n") + + // Getting Device Model + append("Device model: ") + .append(DeviceInfoUtil.getDeviceModel()) + .append("\n") + + // Getting Device Name + append("Device: ") + .append(DeviceInfoUtil.getDevice()) + .append("\n") + + // Getting Network Type + append("Network type: ") + .append(DeviceInfoUtil.getConnectionType(context)) + .append("\n") + + // Getting App Version + append("App version name: ") + .append(context.getVersionNameWithSha()) + .append("\n") + + // Getting Username + append("User name: ") + .append(sessionManager.userName) + .append("\n") + } + } + + /** + * Determines if the log sending process requires the app to be in the foreground. + * @return False as it does not require foreground execution. + */ + override fun requiresForeground(): Boolean = false + + /** + * Sends logs to developers. Implementation can be extended. + */ + override fun send( + context: Context, + errorContent: CrashReportData, + extras: Bundle) { + // Add logic here if needed. + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.java b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.java deleted file mode 100644 index a2ebeec68..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.java +++ /dev/null @@ -1,145 +0,0 @@ -package fr.free.nrw.commons.logging; - -import android.util.Log; - -import androidx.annotation.NonNull; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Locale; -import java.util.concurrent.Executor; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.encoder.PatternLayoutEncoder; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.rolling.FixedWindowRollingPolicy; -import ch.qos.logback.core.rolling.RollingFileAppender; -import ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy; -import timber.log.Timber; - -/** - * Extends Timber's debug tree to write logs to a file - */ -public class FileLoggingTree extends Timber.DebugTree implements LogLevelSettableTree { - private final Logger logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); - private int logLevel; - private final String logFileName; - private int fileSize; - private FixedWindowRollingPolicy rollingPolicy; - private final Executor executor; - - public FileLoggingTree(int logLevel, - String logFileName, - String logDirectory, - int fileSizeInKb, - Executor executor) { - this.logLevel = logLevel; - this.logFileName = logFileName; - this.fileSize = fileSizeInKb; - configureLogger(logDirectory); - this.executor = executor; - } - - /** - * Can be overridden to change file's log level - * @param logLevel - */ - @Override - public void setLogLevel(int logLevel) { - this.logLevel = logLevel; - } - - /** - * Check and log any message - * @param priority - * @param tag - * @param message - * @param t - */ - @Override - protected void log(final int priority, final String tag, @NonNull final String message, Throwable t) { - executor.execute(() -> logMessage(priority, tag, message)); - - } - - /** - * Log any message based on the priority - * @param priority - * @param tag - * @param message - */ - private void logMessage(int priority, String tag, String message) { - String messageWithTag = String.format("[%s] : %s", tag, message); - switch (priority) { - case Log.VERBOSE: - logger.trace(messageWithTag); - break; - case Log.DEBUG: - logger.debug(messageWithTag); - break; - case Log.INFO: - logger.info(messageWithTag); - break; - case Log.WARN: - logger.warn(messageWithTag); - break; - case Log.ERROR: - logger.error(messageWithTag); - break; - case Log.ASSERT: - logger.error(messageWithTag); - break; - } - } - - /** - * Checks if a particular log line should be logged in the file or not - * @param priority - * @return - */ - @Override - protected boolean isLoggable(int priority) { - return priority >= logLevel; - } - - /** - * Configures the logger with a file size rolling policy (SizeBasedTriggeringPolicy) - * https://github.com/tony19/logback-android/wiki - * @param logDir - */ - private void configureLogger(String logDir) { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - loggerContext.reset(); - - RollingFileAppender rollingFileAppender = new RollingFileAppender<>(); - rollingFileAppender.setContext(loggerContext); - rollingFileAppender.setFile(logDir + "/" + logFileName + ".0.log"); - - rollingPolicy = new FixedWindowRollingPolicy(); - rollingPolicy.setContext(loggerContext); - rollingPolicy.setMinIndex(1); - rollingPolicy.setMaxIndex(4); - rollingPolicy.setParent(rollingFileAppender); - rollingPolicy.setFileNamePattern(logDir + "/" + logFileName + ".%i.log"); - rollingPolicy.start(); - - SizeBasedTriggeringPolicy triggeringPolicy = new SizeBasedTriggeringPolicy<>(); - triggeringPolicy.setContext(loggerContext); - triggeringPolicy.setMaxFileSize(String.format(Locale.ENGLISH, "%dKB", fileSize)); - triggeringPolicy.start(); - - PatternLayoutEncoder encoder = new PatternLayoutEncoder(); - encoder.setContext(loggerContext); - encoder.setPattern("%-27(%date{ISO8601}) [%-5level] [%thread] %msg%n"); - encoder.start(); - - rollingFileAppender.setEncoder(encoder); - rollingFileAppender.setRollingPolicy(rollingPolicy); - rollingFileAppender.setTriggeringPolicy(triggeringPolicy); - rollingFileAppender.start(); - ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) - LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); - logger.addAppender(rollingFileAppender); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt new file mode 100644 index 000000000..5c6c55f1a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt @@ -0,0 +1,133 @@ +package fr.free.nrw.commons.logging + +import android.util.Log + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import java.util.Locale +import java.util.concurrent.Executor + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.rolling.FixedWindowRollingPolicy +import ch.qos.logback.core.rolling.RollingFileAppender +import ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy +import timber.log.Timber + + +/** + * Extends Timber's debug tree to write logs to a file. + */ +class FileLoggingTree( + private var logLevel: Int, + private val logFileName: String, + logDirectory: String, + private val fileSizeInKb: Int, + private val executor: Executor +) : Timber.DebugTree(), LogLevelSettableTree { + + private val logger: Logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) + private lateinit var rollingPolicy: FixedWindowRollingPolicy + + init { + configureLogger(logDirectory) + } + + /** + * Can be overridden to change the file's log level. + * @param logLevel The new log level. + */ + override fun setLogLevel(logLevel: Int) { + this.logLevel = logLevel + } + + /** + * Checks and logs any message. + * @param priority The priority of the log message. + * @param tag The tag associated with the log message. + * @param message The log message. + * @param t An optional throwable. + */ + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + executor.execute { + logMessage(priority, tag.orEmpty(), message) + } + } + + /** + * Logs a message based on the priority. + * @param priority The priority of the log message. + * @param tag The tag associated with the log message. + * @param message The log message. + */ + private fun logMessage(priority: Int, tag: String, message: String) { + val messageWithTag = "[$tag] : $message" + when (priority) { + Log.VERBOSE -> logger.trace(messageWithTag) + Log.DEBUG -> logger.debug(messageWithTag) + Log.INFO -> logger.info(messageWithTag) + Log.WARN -> logger.warn(messageWithTag) + Log.ERROR, Log.ASSERT -> logger.error(messageWithTag) + } + } + + /** + * Checks if a particular log line should be logged in the file or not. + * @param priority The priority of the log message. + * @return True if the log message should be logged, false otherwise. + */ + @Deprecated("Deprecated in Java") + override fun isLoggable(priority: Int): Boolean { + return priority >= logLevel + } + + /** + * Configures the logger with a file size rolling policy (SizeBasedTriggeringPolicy). + * https://github.com/tony19/logback-android/wiki + * @param logDir The directory where logs should be stored. + */ + private fun configureLogger(logDir: String) { + val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext + loggerContext.reset() + + val rollingFileAppender = RollingFileAppender().apply { + context = loggerContext + file = "$logDir/$logFileName.0.log" + } + + rollingPolicy = FixedWindowRollingPolicy().apply { + context = loggerContext + minIndex = 1 + maxIndex = 4 + setParent(rollingFileAppender) + fileNamePattern = "$logDir/$logFileName.%i.log" + start() + } + + val triggeringPolicy = SizeBasedTriggeringPolicy().apply { + context = loggerContext + maxFileSize = "$fileSizeInKb" + start() + } + + val encoder = PatternLayoutEncoder().apply { + context = loggerContext + pattern = "%-27(%date{ISO8601}) [%-5level] [%thread] %msg%n" + start() + } + + rollingFileAppender.apply { + this.encoder = encoder + rollingPolicy = rollingPolicy + this.triggeringPolicy = triggeringPolicy + start() + } + + val rootLogger = LoggerFactory.getLogger( + Logger.ROOT_LOGGER_NAME + ) as ch.qos.logback.classic.Logger + rootLogger.addAppender(rollingFileAppender) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.java b/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.java deleted file mode 100644 index 5eeca6d3e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.java +++ /dev/null @@ -1,8 +0,0 @@ -package fr.free.nrw.commons.logging; - -/** - * Can be implemented to set the log level for file tree - */ -public interface LogLevelSettableTree { - void setLogLevel(int logLevel); -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.kt b/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.kt new file mode 100644 index 000000000..babe78121 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.kt @@ -0,0 +1,8 @@ +package fr.free.nrw.commons.logging + +/** + * Can be implemented to set the log level for file tree + */ +interface LogLevelSettableTree { + fun setLogLevel(logLevel: Int) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.java b/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.java deleted file mode 100644 index c28b2145b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.java +++ /dev/null @@ -1,48 +0,0 @@ -package fr.free.nrw.commons.logging; - -import android.os.Environment; - -import fr.free.nrw.commons.upload.FileUtils; -import fr.free.nrw.commons.utils.ConfigUtils; - -/** - * Returns the log directory - */ -public final class LogUtils { - private LogUtils() { - } - - /** - * Returns the directory for saving logs on the device - * - * @return - */ - public static String getLogDirectory() { - String dirPath; - if (ConfigUtils.isBetaFlavour()) { - dirPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/beta"; - } else { - dirPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/prod"; - } - - FileUtils.recursivelyCreateDirs(dirPath); - return dirPath; - } - - /** - * Returns the directory for saving logs on the device - * - * @return - */ - public static String getLogZipDirectory() { - String dirPath; - if (ConfigUtils.isBetaFlavour()) { - dirPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/beta/zip"; - } else { - dirPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/prod/zip"; - } - - FileUtils.recursivelyCreateDirs(dirPath); - return dirPath; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.kt b/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.kt new file mode 100644 index 000000000..6c91d92dd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.kt @@ -0,0 +1,57 @@ +package fr.free.nrw.commons.logging + +import android.os.Environment + +import fr.free.nrw.commons.upload.FileUtils +import fr.free.nrw.commons.utils.ConfigUtils + + +/** + * Returns the log directory + */ +object LogUtils { + + /** + * Returns the directory for saving logs on the device. + * + * @return The path to the log directory. + */ + fun getLogDirectory(): String { + val dirPath = if (ConfigUtils.isBetaFlavour) { + "${Environment + .getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + )}/logs/beta" + } else { + "${Environment + .getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + )}/logs/prod" + } + + FileUtils.recursivelyCreateDirs(dirPath) + return dirPath + } + + /** + * Returns the directory for saving zipped logs on the device. + * + * @return The path to the zipped log directory. + */ + fun getLogZipDirectory(): String { + val dirPath = if (ConfigUtils.isBetaFlavour) { + "${Environment + .getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + )}/logs/beta/zip" + } else { + "${Environment + .getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + )}/logs/prod/zip" + } + + FileUtils.recursivelyCreateDirs(dirPath) + return dirPath + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.java b/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.java deleted file mode 100644 index 68f7bd78c..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.java +++ /dev/null @@ -1,201 +0,0 @@ -package fr.free.nrw.commons.logging; - -import static org.acra.ACRA.getErrorReporter; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.FileProvider; - -import org.acra.data.CrashReportData; -import org.acra.sender.ReportSender; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.charset.Charset; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.SessionManager; -import timber.log.Timber; - -/** - * Abstract class that implements Acra's log sender - */ -public abstract class LogsSender implements ReportSender { - - String mailTo; - String logFileName; - String emailSubject; - String emailBody; - - private final SessionManager sessionManager; - - LogsSender(SessionManager sessionManager) { - this.sessionManager = sessionManager; - } - - /** - * Overrides send method of ACRA's ReportSender to send logs - * - * @param context - * @param report - */ - @Override - public void send(@NonNull final Context context, @Nullable CrashReportData report) { - sendLogs(context, report); - } - - /** - * Gets zipped log files and sends it via email. Can be modified to change the send log mechanism - * - * @param context - * @param report - */ - private void sendLogs(Context context, CrashReportData report) { - final Uri logFileUri = getZippedLogFileUri(context, report); - if (logFileUri != null) { - sendEmail(context, logFileUri); - } else { - getErrorReporter().handleSilentException(null); - } - } - - /*** - * Provides any extra information that you want to send. The return value will be - * delivered inside the report verbatim - * - * @return - */ - protected abstract String getExtraInfo(); - - /** - * Fires an intent to send email with logs - * - * @param context - * @param logFileUri - */ - private void sendEmail(Context context, Uri logFileUri) { - String subject = emailSubject; - String body = emailBody; - - Intent emailIntent = new Intent(Intent.ACTION_SEND); - emailIntent.setType("message/rfc822"); - emailIntent.putExtra(Intent.EXTRA_EMAIL, new String[]{mailTo}); - emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject); - emailIntent.putExtra(Intent.EXTRA_TEXT, body); - emailIntent.putExtra(Intent.EXTRA_STREAM, logFileUri); - emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - emailIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - context.startActivity(Intent.createChooser(emailIntent, context.getString(R.string.share_logs_using))); - } - - /** - * Returns the URI for the zipped log file - * - * @param report - * @return - */ - private Uri getZippedLogFileUri(Context context, CrashReportData report) { - try { - StringBuilder builder = new StringBuilder(); - if (report != null) { - attachCrashInfo(report, builder); - } - attachUserInfo(builder); - attachExtraInfo(builder); - byte[] metaData = builder.toString().getBytes(Charset.forName("UTF-8")); - File zipFile = new File(LogUtils.getLogZipDirectory(), logFileName); - writeLogToZipFile(metaData, zipFile); - return FileProvider - .getUriForFile(context, - context.getApplicationContext().getPackageName() + ".provider", zipFile); - } catch (IOException e) { - Timber.w(e, "Error in generating log file"); - } - return null; - } - - /** - * Checks if there are any pending crash reports and attaches them to the logs - * - * @param report - * @param builder - */ - private void attachCrashInfo(CrashReportData report, StringBuilder builder) { - if (report == null) { - return; - } - builder.append(report); - } - - /** - * Attaches username to the the meta_data file - * - * @param builder - */ - private void attachUserInfo(StringBuilder builder) { - builder.append("MediaWiki Username = ").append(sessionManager.getUserName()).append("\n"); - } - - /** - * Gets any extra meta information to be attached with the log files - * - * @param builder - */ - private void attachExtraInfo(StringBuilder builder) { - String infoToBeAttached = getExtraInfo(); - builder.append(infoToBeAttached); - builder.append("\n"); - } - - /** - * Zips the logs and meta information - * - * @param metaData - * @param zipFile - * @throws IOException - */ - private void writeLogToZipFile(byte[] metaData, File zipFile) throws IOException { - FileOutputStream fos = new FileOutputStream(zipFile); - BufferedOutputStream bos = new BufferedOutputStream(fos); - ZipOutputStream zos = new ZipOutputStream(bos); - File logDir = new File(LogUtils.getLogDirectory()); - - if (!logDir.exists() || logDir.listFiles().length == 0) { - return; - } - - byte[] buffer = new byte[1024]; - for (File file : logDir.listFiles()) { - if (file.isDirectory()) { - continue; - } - FileInputStream fis = new FileInputStream(file); - BufferedInputStream bis = new BufferedInputStream(fis); - zos.putNextEntry(new ZipEntry(file.getName())); - int length; - while ((length = bis.read(buffer)) > 0) { - zos.write(buffer, 0, length); - } - zos.closeEntry(); - bis.close(); - } - - //attach metadata as a separate file - zos.putNextEntry(new ZipEntry("meta_data.txt")); - zos.write(metaData); - zos.closeEntry(); - - zos.flush(); - zos.close(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.kt b/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.kt new file mode 100644 index 000000000..cd6bb7d70 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.kt @@ -0,0 +1,193 @@ +package fr.free.nrw.commons.logging + +import android.content.Context +import android.content.Intent +import android.net.Uri + +import androidx.core.content.FileProvider + +import org.acra.data.CrashReportData +import org.acra.sender.ReportSender + +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import org.acra.ACRA.errorReporter +import timber.log.Timber + + +/** + * Abstract class that implements Acra's log sender. + */ +abstract class LogsSender( + private val sessionManager: SessionManager +): ReportSender { + + var mailTo: String? = null + var logFileName: String? = null + var emailSubject: String? = null + var emailBody: String? = null + + /** + * Overrides the send method of ACRA's ReportSender to send logs. + * + * @param context The context in which to send the logs. + * @param report The crash report data, if any. + */ + fun sendWithNullable(context: Context, report: CrashReportData?) { + if (report == null) { + errorReporter.handleSilentException(null) + return + } + send(context, report) + } + + override fun send(context: Context, report: CrashReportData) { + sendLogs(context, report) + } + + /** + * Gets zipped log files and sends them via email. Can be modified to change the send + * log mechanism. + * + * @param context The context in which to send the logs. + * @param report The crash report data, if any. + */ + private fun sendLogs(context: Context, report: CrashReportData?) { + val logFileUri = getZippedLogFileUri(context, report) + if (logFileUri != null) { + sendEmail(context, logFileUri) + } else { + errorReporter.handleSilentException(null) + + } + } + + /** + * Provides any extra information that you want to send. The return value will be + * delivered inside the report verbatim. + * + * @return A string containing the extra information. + */ + protected abstract fun getExtraInfo(): String + + /** + * Fires an intent to send an email with logs. + * + * @param context The context in which to send the email. + * @param logFileUri The URI of the zipped log file. + */ + private fun sendEmail(context: Context, logFileUri: Uri) { + val emailIntent = Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + putExtra(Intent.EXTRA_EMAIL, arrayOf(mailTo)) + putExtra(Intent.EXTRA_SUBJECT, emailSubject) + putExtra(Intent.EXTRA_TEXT, emailBody) + putExtra(Intent.EXTRA_STREAM, logFileUri) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(emailIntent, context.getString(R.string.share_logs_using))) + } + + /** + * Returns the URI for the zipped log file. + * + * @param context The context for file URI generation. + * @param report The crash report data, if any. + * @return The URI of the zipped log file or null if an error occurs. + */ + private fun getZippedLogFileUri(context: Context, report: CrashReportData?): Uri? { + return try { + val builder = StringBuilder().apply { + report?.let { attachCrashInfo(it, this) } + attachUserInfo(this) + attachExtraInfo(this) + } + val metaData = builder.toString().toByteArray(Charsets.UTF_8) + val zipFile = File(LogUtils.getLogZipDirectory(), logFileName ?: "logs.zip") + writeLogToZipFile(metaData, zipFile) + FileProvider.getUriForFile( + context, + "${context.applicationContext.packageName}.provider", + zipFile + ) + } catch (e: IOException) { + Timber.w(e, "Error in generating log file") + null + } + } + + /** + * Checks if there are any pending crash reports and attaches them to the logs. + * + * @param report The crash report data, if any. + * @param builder The string builder to append crash info. + */ + private fun attachCrashInfo(report: CrashReportData?, builder: StringBuilder) { + if(report != null) { + builder.append(report) + } + } + + /** + * Attaches the username to the metadata file. + * + * @param builder The string builder to append user info. + */ + private fun attachUserInfo(builder: StringBuilder) { + builder.append("MediaWiki Username = ").append(sessionManager.userName).append("\n") + } + + /** + * Gets any extra metadata information to be attached with the log files. + * + * @param builder The string builder to append extra info. + */ + private fun attachExtraInfo(builder: StringBuilder) { + builder.append(getExtraInfo()).append("\n") + } + + /** + * Zips the logs and metadata information. + * + * @param metaData The metadata to be added to the zip file. + * @param zipFile The zip file to write to. + * @throws IOException If an I/O error occurs. + */ + @Throws(IOException::class) + private fun writeLogToZipFile(metaData: ByteArray, zipFile: File) { + val logDir = File(LogUtils.getLogDirectory()) + if (!logDir.exists() || logDir.listFiles().isNullOrEmpty()) return + + ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos -> + val buffer = ByteArray(1024) + logDir.listFiles()?.forEach { file -> + if (file.isDirectory) return@forEach + FileInputStream(file).use { fis -> + BufferedInputStream(fis).use { bis -> + zos.putNextEntry(ZipEntry(file.name)) + var length: Int + while (bis.read(buffer).also { length = it } > 0) { + zos.write(buffer, 0, length) + } + zos.closeEntry() + } + } + } + + // Attach metadata as a separate file. + zos.putNextEntry(ZipEntry("meta_data.txt")) + zos.write(metaData) + zos.closeEntry() + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java deleted file mode 100644 index 7336c1b40..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java +++ /dev/null @@ -1,1540 +0,0 @@ -package fr.free.nrw.commons.media; - -import static android.app.Activity.RESULT_CANCELED; -import static android.app.Activity.RESULT_OK; -import static android.view.View.GONE; -import static android.view.View.VISIBLE; -import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_NEEDING_CATEGORIES; -import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_UNCATEGORISED; -import static fr.free.nrw.commons.description.EditDescriptionConstants.LIST_OF_DESCRIPTION_AND_CAPTION; -import static fr.free.nrw.commons.description.EditDescriptionConstants.UPDATED_WIKITEXT; -import static fr.free.nrw.commons.description.EditDescriptionConstants.WIKITEXT; -import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION; -import static fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources; - -import android.annotation.SuppressLint; -import android.app.AlertDialog; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.drawable.Animatable; -import android.net.Uri; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.View; -import android.view.View.OnKeyListener; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.view.ViewTreeObserver.OnGlobalLayoutListener; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.Spinner; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentTransaction; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.drawee.controller.BaseControllerListener; -import com.facebook.drawee.controller.ControllerListener; -import com.facebook.drawee.interfaces.DraweeController; -import com.facebook.imagepipeline.image.ImageInfo; -import com.facebook.imagepipeline.request.ImageRequest; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.CameraPosition; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.LocationPicker.LocationPicker; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.MediaDataExtractor; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.actions.ThanksClient; -import fr.free.nrw.commons.auth.AccountUtil; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.auth.csrf.CsrfTokenClient; -import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException; -import fr.free.nrw.commons.category.CategoryClient; -import fr.free.nrw.commons.category.CategoryDetailsActivity; -import fr.free.nrw.commons.category.CategoryEditHelper; -import fr.free.nrw.commons.contributions.ContributionsFragment; -import fr.free.nrw.commons.coordinates.CoordinateEditHelper; -import fr.free.nrw.commons.databinding.FragmentMediaDetailBinding; -import fr.free.nrw.commons.delete.DeleteHelper; -import fr.free.nrw.commons.delete.ReasonBuilder; -import fr.free.nrw.commons.description.DescriptionEditActivity; -import fr.free.nrw.commons.description.DescriptionEditHelper; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.language.AppLanguageLookUpTable; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.profile.ProfileActivity; -import fr.free.nrw.commons.review.ReviewHelper; -import fr.free.nrw.commons.settings.Prefs; -import fr.free.nrw.commons.upload.UploadMediaDetail; -import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment; -import fr.free.nrw.commons.upload.depicts.DepictsFragment; -import fr.free.nrw.commons.utils.DateUtil; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.PermissionUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.utils.ViewUtilWrapper; -import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage; -import io.reactivex.Observable; -import io.reactivex.ObservableSource; -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Date; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.Callable; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.inject.Inject; -import javax.inject.Named; -import org.apache.commons.lang3.StringUtils; -import timber.log.Timber; - -public class MediaDetailFragment extends CommonsDaggerSupportFragment implements - CategoryEditHelper.Callback { - - private static final int REQUEST_CODE = 1001; - private static final int REQUEST_CODE_EDIT_DESCRIPTION = 1002; - private static final String IMAGE_BACKGROUND_COLOR = "image_background_color"; - static final int DEFAULT_IMAGE_BACKGROUND_COLOR = 0; - - private boolean editable; - private boolean isCategoryImage; - private MediaDetailPagerFragment.MediaDetailProvider detailProvider; - private int index; - private boolean isDeleted = false; - private boolean isWikipediaButtonDisplayed; - private Callback callback; - - @Inject - LocationServiceManager locationManager; - - - public static MediaDetailFragment forMedia(int index, boolean editable, boolean isCategoryImage, boolean isWikipediaButtonDisplayed) { - MediaDetailFragment mf = new MediaDetailFragment(); - Bundle state = new Bundle(); - state.putBoolean("editable", editable); - state.putBoolean("isCategoryImage", isCategoryImage); - state.putInt("index", index); - state.putInt("listIndex", 0); - state.putInt("listTop", 0); - state.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed); - mf.setArguments(state); - - return mf; - } - - @Inject - SessionManager sessionManager; - - @Inject - MediaDataExtractor mediaDataExtractor; - @Inject - ReasonBuilder reasonBuilder; - @Inject - DeleteHelper deleteHelper; - @Inject - ReviewHelper reviewHelper; - @Inject - CategoryEditHelper categoryEditHelper; - @Inject - CoordinateEditHelper coordinateEditHelper; - @Inject - DescriptionEditHelper descriptionEditHelper; - @Inject - ViewUtilWrapper viewUtil; - @Inject - CategoryClient categoryClient; - @Inject - ThanksClient thanksClient; - @Inject - @Named("default_preferences") - JsonKvStore applicationKvStore; - - private int initialListTop = 0; - private FragmentMediaDetailBinding binding; - String descriptionHtmlCode; - - - - - private ArrayList categoryNames = new ArrayList<>(); - private String categorySearchQuery; - - /** - * Depicts is a feature part of Structured data. Multiple Depictions can be added for an image just like categories. - * However unlike categories depictions is multi-lingual - * Ex: key: en value: monument - */ - private ImageInfo imageInfoCache; - private int oldWidthOfImageView; - private int newWidthOfImageView; - private boolean heightVerifyingBoolean = true; // helps in maintaining aspect ratio - private ViewTreeObserver.OnGlobalLayoutListener layoutListener; // for layout stuff, only used once! - - //Had to make this class variable, to implement various onClicks, which access the media, also I fell why make separate variables when one can serve the purpose - private Media media; - private ArrayList reasonList; - private ArrayList reasonListEnglishMappings; - - /** - * Height stores the height of the frame layout as soon as it is initialised and updates itself on - * configuration changes. - * Used to adjust aspect ratio of image when length of the image is too large. - */ - private int frameLayoutHeight; - - /** - * Minimum height of the metadata, in pixels. - * Images with a very narrow aspect ratio will be reduced so that the metadata information panel always has at least this height. - */ - private int minimumHeightOfMetadata = 200; - - final static String NOMINATING_FOR_DELETION_MEDIA = "Nominating for deletion %s"; - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt("index", index); - outState.putBoolean("editable", editable); - outState.putBoolean("isCategoryImage", isCategoryImage); - outState.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed); - - getScrollPosition(); - outState.putInt("listTop", initialListTop); - } - - private void getScrollPosition() { - initialListTop = binding.mediaDetailScrollView.getScrollY(); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - if (getParentFragment() != null - && getParentFragment() instanceof MediaDetailPagerFragment) { - detailProvider = - ((MediaDetailPagerFragment) getParentFragment()).getMediaDetailProvider(); - } - if (savedInstanceState != null) { - editable = savedInstanceState.getBoolean("editable"); - isCategoryImage = savedInstanceState.getBoolean("isCategoryImage"); - isWikipediaButtonDisplayed = savedInstanceState.getBoolean("isWikipediaButtonDisplayed"); - index = savedInstanceState.getInt("index"); - initialListTop = savedInstanceState.getInt("listTop"); - } else { - editable = getArguments().getBoolean("editable"); - isCategoryImage = getArguments().getBoolean("isCategoryImage"); - isWikipediaButtonDisplayed = getArguments().getBoolean("isWikipediaButtonDisplayed"); - index = getArguments().getInt("index"); - initialListTop = 0; - } - - reasonList = new ArrayList<>(); - reasonList.add(getString(R.string.deletion_reason_uploaded_by_mistake)); - reasonList.add(getString(R.string.deletion_reason_publicly_visible)); - reasonList.add(getString(R.string.deletion_reason_not_interesting)); - reasonList.add(getString(R.string.deletion_reason_no_longer_want_public)); - reasonList.add(getString(R.string.deletion_reason_bad_for_my_privacy)); - - // Add corresponding mappings in english locale so that we can upload it in deletion request - reasonListEnglishMappings = new ArrayList<>(); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_uploaded_by_mistake)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_publicly_visible)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_not_interesting)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_no_longer_want_public)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_bad_for_my_privacy)); - - binding = FragmentMediaDetailBinding.inflate(inflater, container, false); - final View view = binding.getRoot(); - - - Utils.setUnderlinedText(binding.seeMore, R.string.nominated_see_more, requireContext()); - - if (isCategoryImage){ - binding.authorLinearLayout.setVisibility(VISIBLE); - } else { - binding.authorLinearLayout.setVisibility(GONE); - } - - if (!sessionManager.isUserLoggedIn()) { - binding.categoryEditButton.setVisibility(GONE); - } - - if(applicationKvStore.getBoolean("login_skipped")){ - binding.nominateDeletion.setVisibility(GONE); - binding.coordinateEdit.setVisibility(GONE); - } - - handleBackEvent(view); - - //set onCLick listeners - binding.mediaDetailLicense.setOnClickListener(v -> onMediaDetailLicenceClicked()); - binding.mediaDetailCoordinates.setOnClickListener(v -> onMediaDetailCoordinatesClicked()); - binding.sendThanks.setOnClickListener(v -> sendThanksToAuthor()); - binding.dummyCaptionDescriptionContainer.setOnClickListener(v -> showCaptionAndDescription()); - binding.mediaDetailImageView.setOnClickListener(v -> launchZoomActivity(binding.mediaDetailImageView)); - binding.categoryEditButton.setOnClickListener(v -> onCategoryEditButtonClicked()); - binding.depictionsEditButton.setOnClickListener(v -> onDepictionsEditButtonClicked()); - binding.seeMore.setOnClickListener(v -> onSeeMoreClicked()); - binding.mediaDetailAuthor.setOnClickListener(v -> onAuthorViewClicked()); - binding.nominateDeletion.setOnClickListener(v -> onDeleteButtonClicked()); - binding.descriptionEdit.setOnClickListener(v -> onDescriptionEditClicked()); - binding.coordinateEdit.setOnClickListener(v -> onUpdateCoordinatesClicked()); - binding.copyWikicode.setOnClickListener(v -> onCopyWikicodeClicked()); - - - /** - * Gets the height of the frame layout as soon as the view is ready and updates aspect ratio - * of the picture. - */ - view.post(new Runnable() { - @Override - public void run() { - frameLayoutHeight = binding.mediaDetailFrameLayout.getMeasuredHeight(); - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - }); - - return view; - } - - public void launchZoomActivity(final View view) { - final boolean hasPermission = PermissionUtils.hasPermission(getActivity(), PermissionUtils.PERMISSIONS_STORAGE); - if (hasPermission) { - launchZoomActivityAfterPermissionCheck(view); - } else { - PermissionUtils.checkPermissionsAndPerformAction(getActivity(), - () -> { - launchZoomActivityAfterPermissionCheck(view); - }, - R.string.storage_permission_title, - R.string.read_storage_permission_rationale, - PermissionUtils.PERMISSIONS_STORAGE - ); - } - } - - /** - * launch zoom acitivity after permission check - * @param view as ImageView - */ - private void launchZoomActivityAfterPermissionCheck(final View view) { - if (media.getImageUrl() != null) { - final Context ctx = view.getContext(); - final Intent zoomableIntent = new Intent(ctx, ZoomableActivity.class); - zoomableIntent.setData(Uri.parse(media.getImageUrl())); - zoomableIntent.putExtra( - ZoomableActivity.ZoomableActivityConstants.ORIGIN, "MediaDetails"); - - int backgroundColor = getImageBackgroundColor(); - if (backgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { - zoomableIntent.putExtra( - ZoomableActivity.ZoomableActivityConstants.PHOTO_BACKGROUND_COLOR, - backgroundColor - ); - } - - ctx.startActivity( - zoomableIntent - ); - } - } - - @Override - public void onResume() { - super.onResume(); - if (getParentFragment() != null && getParentFragment().getParentFragment() != null) { - //Added a check because, not necessarily, the parent fragment will have a parent fragment, say - // in the case when MediaDetailPagerFragment is directly started by the CategoryImagesActivity - if (getParentFragment() instanceof ContributionsFragment) { - ((ContributionsFragment) (getParentFragment() - .getParentFragment())).binding.cardViewNearby - .setVisibility(View.GONE); - } - } - // detail provider is null when fragment is shown in review activity - if (detailProvider != null) { - media = detailProvider.getMediaAtPosition(index); - } else { - media = getArguments().getParcelable("media"); - } - - if(media != null && applicationKvStore.getBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - enableProgressBar(); - } - - if (AccountUtil.getUserName(getContext()) != null && media != null - && AccountUtil.getUserName(getContext()).equals(media.getAuthor())) { - binding.sendThanks.setVisibility(GONE); - } else { - binding.sendThanks.setVisibility(VISIBLE); - } - - binding.mediaDetailScrollView.getViewTreeObserver().addOnGlobalLayoutListener( - new OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - if (getContext() == null) { - return; - } - binding.mediaDetailScrollView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - oldWidthOfImageView = binding.mediaDetailScrollView.getWidth(); - if(media != null) { - displayMediaDetails(); - } - } - } - ); - binding.progressBarEdit.setVisibility(GONE); - binding.descriptionEdit.setVisibility(VISIBLE); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - binding.mediaDetailScrollView.getViewTreeObserver().addOnGlobalLayoutListener( - new OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - /** - * We update the height of the frame layout as the configuration changes. - */ - binding.mediaDetailFrameLayout.post(new Runnable() { - @Override - public void run() { - frameLayoutHeight = binding.mediaDetailFrameLayout.getMeasuredHeight(); - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - }); - if (binding.mediaDetailScrollView.getWidth() != oldWidthOfImageView) { - if (newWidthOfImageView == 0) { - newWidthOfImageView = binding.mediaDetailScrollView.getWidth(); - updateAspectRatio(newWidthOfImageView); - } - binding.mediaDetailScrollView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - } - } - } - ); - // Ensuring correct aspect ratio for landscape mode - if (heightVerifyingBoolean) { - updateAspectRatio(newWidthOfImageView); - heightVerifyingBoolean = false; - } else { - updateAspectRatio(oldWidthOfImageView); - heightVerifyingBoolean = true; - } - } - - private void displayMediaDetails() { - setTextFields(media); - compositeDisposable.addAll( - mediaDataExtractor.refresh(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onMediaRefreshed, Timber::e), - mediaDataExtractor.getCurrentWikiText( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::updateCategoryList, Timber::e), - mediaDataExtractor.checkDeletionRequestExists(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDeletionPageExists, Timber::e), - mediaDataExtractor.fetchDiscussion(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDiscussionLoaded, Timber::e) - ); - } - - private void onMediaRefreshed(Media media) { - media.setCategories(this.media.getCategories()); - this.media = media; - setTextFields(media); - compositeDisposable.addAll( - mediaDataExtractor.fetchDepictionIdsAndLabels(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDepictionsLoaded, Timber::e) - ); - // compositeDisposable.add(disposable); - } - - private void onDiscussionLoaded(String discussion) { - binding.mediaDetailDisc.setText(prettyDiscussion(discussion.trim())); - } - - private void onDeletionPageExists(Boolean deletionPageExists) { - if (AccountUtil.getUserName(getContext()) == null && !AccountUtil.getUserName(getContext()).equals(media.getAuthor())) { - binding.nominateDeletion.setVisibility(GONE); - binding.nominatedDeletionBanner.setVisibility(GONE); - } else if (deletionPageExists) { - if (applicationKvStore.getBoolean( - String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - applicationKvStore.remove( - String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl())); - binding.progressBarDeletion.setVisibility(GONE); - } - binding.nominateDeletion.setVisibility(GONE); - - binding.nominatedDeletionBanner.setVisibility(VISIBLE); - } else if (!isCategoryImage) { - binding.nominateDeletion.setVisibility(VISIBLE); - binding.nominatedDeletionBanner.setVisibility(GONE); - } - } - - private void onDepictionsLoaded(List idAndCaptions){ - binding.depictsLayout.setVisibility(idAndCaptions.isEmpty() ? GONE : VISIBLE); - binding.depictionsEditButton.setVisibility(idAndCaptions.isEmpty() ? GONE : VISIBLE); - buildDepictionList(idAndCaptions); - } - - /** - * By clicking on the edit depictions button, it will send user to depict fragment - */ - - public void onDepictionsEditButtonClicked() { - binding.mediaDetailDepictionContainer.removeAllViews(); - binding.depictionsEditButton.setVisibility(GONE); - final Fragment depictsFragment = new DepictsFragment(); - final Bundle bundle = new Bundle(); - bundle.putParcelable("Existing_Depicts", media); - depictsFragment.setArguments(bundle); - final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); - transaction.replace(R.id.mediaDetailFrameLayout, depictsFragment); - transaction.addToBackStack(null); - transaction.commit(); - } - /** - * The imageSpacer is Basically a transparent overlay for the SimpleDraweeView - * which holds the image to be displayed( moreover this image is out of - * the scroll view ) - * - * - * If the image is sufficiently large i.e. the image height extends the view height, we reduce - * the height and change the width to maintain the aspect ratio, otherwise image takes up the - * total possible width and height is adjusted accordingly. - * - * @param scrollWidth the current width of the scrollView - */ - private void updateAspectRatio(int scrollWidth) { - if (imageInfoCache != null) { - int finalHeight = (scrollWidth*imageInfoCache.getHeight()) / imageInfoCache.getWidth(); - ViewGroup.LayoutParams params = binding.mediaDetailImageView.getLayoutParams(); - ViewGroup.LayoutParams spacerParams = binding.mediaDetailImageViewSpacer.getLayoutParams(); - params.width = scrollWidth; - if(finalHeight > frameLayoutHeight - minimumHeightOfMetadata) { - - // Adjust the height and width of image. - int temp = frameLayoutHeight - minimumHeightOfMetadata; - params.width = (scrollWidth*temp) / finalHeight; - finalHeight = temp; - - } - params.height = finalHeight; - spacerParams.height = finalHeight; - binding.mediaDetailImageView.setLayoutParams(params); - binding.mediaDetailImageViewSpacer.setLayoutParams(spacerParams); - } - } - - private final ControllerListener aspectRatioListener = new BaseControllerListener() { - @Override - public void onIntermediateImageSet(String id, @Nullable ImageInfo imageInfo) { - imageInfoCache = imageInfo; - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - @Override - public void onFinalImageSet(String id, @Nullable ImageInfo imageInfo, @Nullable Animatable animatable) { - imageInfoCache = imageInfo; - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - }; - - /** - * Uses two image sources. - * - low resolution thumbnail is shown initially - * - when the high resolution image is available, it replaces the low resolution image - */ - private void setupImageView() { - int imageBackgroundColor = getImageBackgroundColor(); - if (imageBackgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { - binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor); - } - - binding.mediaDetailImageView.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder); - binding.mediaDetailImageView.getHierarchy().setFailureImage(R.drawable.image_placeholder); - - DraweeController controller = Fresco.newDraweeControllerBuilder() - .setLowResImageRequest(ImageRequest.fromUri(media != null ? media.getThumbUrl() : null)) - .setRetainImageOnFailure(true) - .setImageRequest(ImageRequest.fromUri(media != null ? media.getImageUrl() : null)) - .setControllerListener(aspectRatioListener) - .setOldController(binding.mediaDetailImageView.getController()) - .build(); - binding.mediaDetailImageView.setController(controller); - } - - private void updateToDoWarning() { - String toDoMessage = ""; - boolean toDoNeeded = false; - boolean categoriesPresent = media.getCategories() == null ? false : (media.getCategories().size() == 0 ? false : true); - - // Check if the presented category is about need of category - if (categoriesPresent) { - for (String category : media.getCategories()) { - if (category.toLowerCase().contains(CATEGORY_NEEDING_CATEGORIES) || - category.toLowerCase().contains(CATEGORY_UNCATEGORISED)) { - categoriesPresent = false; - } - break; - } - } - if (!categoriesPresent) { - toDoNeeded = true; - toDoMessage += getString(R.string.missing_category); - } - if (isWikipediaButtonDisplayed) { - toDoNeeded = true; - toDoMessage += (toDoMessage.isEmpty()) ? "" : "\n" + getString(R.string.missing_article); - } - - if (toDoNeeded) { - toDoMessage = getString(R.string.todo_improve) + "\n" + toDoMessage; - binding.toDoLayout.setVisibility(VISIBLE); - binding.toDoReason.setText(toDoMessage); - } else { - binding.toDoLayout.setVisibility(GONE); - } - } - - @Override - public void onDestroyView() { - if (layoutListener != null && getView() != null) { - getView().getViewTreeObserver().removeGlobalOnLayoutListener(layoutListener); // old Android was on crack. CRACK IS WHACK - layoutListener = null; - } - - compositeDisposable.clear(); - super.onDestroyView(); - } - - private void setTextFields(Media media) { - setupImageView(); - binding.mediaDetailTitle.setText(media.getDisplayTitle()); - binding.mediaDetailDesc.setHtmlText(prettyDescription(media)); - binding.mediaDetailLicense.setText(prettyLicense(media)); - binding.mediaDetailCoordinates.setText(prettyCoordinates(media)); - binding.mediaDetailuploadeddate.setText(prettyUploadedDate(media)); - if (prettyCaption(media).equals(getContext().getString(R.string.detail_caption_empty))) { - binding.captionLayout.setVisibility(GONE); - } else { - binding.mediaDetailCaption.setText(prettyCaption(media)); - } - - categoryNames.clear(); - categoryNames.addAll(media.getCategories()); - - if (media.getAuthor() == null || media.getAuthor().equals("")) { - binding.authorLinearLayout.setVisibility(GONE); - } else { - binding.mediaDetailAuthor.setText(media.getAuthor()); - } - } - - /** - * Gets new categories from the WikiText and updates it on the UI - * - * @param s WikiText - */ - private void updateCategoryList(final String s) { - final List allCategories = new ArrayList(); - int i = s.indexOf("[[Category:"); - while(i != -1){ - final String category = s.substring(i+11, s.indexOf("]]", i)); - allCategories.add(category); - i = s.indexOf("]]", i); - i = s.indexOf("[[Category:", i); - } - media.setCategories(allCategories); - if (allCategories.isEmpty()) { - // Stick in a filler element. - allCategories.add(getString(R.string.detail_panel_cats_none)); - } - binding.categoryEditButton.setVisibility(VISIBLE); - rebuildCatList(allCategories); - } - - /** - * Updates the categories - */ - public void updateCategories() { - List allCategories = new ArrayList(media.getAddedCategories()); - media.setCategories(allCategories); - if (allCategories.isEmpty()) { - // Stick in a filler element. - allCategories.add(getString(R.string.detail_panel_cats_none)); - } - - rebuildCatList(allCategories); - } - - /** - * Populates media details fragment with depiction list - * @param idAndCaptions - */ - private void buildDepictionList(List idAndCaptions) { - binding.mediaDetailDepictionContainer.removeAllViews(); - String locale = Locale.getDefault().getLanguage(); - for (IdAndCaptions idAndCaption : idAndCaptions) { - binding.mediaDetailDepictionContainer.addView(buildDepictLabel( - getDepictionCaption(idAndCaption, locale), - idAndCaption.getId(), - binding.mediaDetailDepictionContainer - )); - } - } - - private String getDepictionCaption(IdAndCaptions idAndCaption, String locale) { - //Check if the Depiction Caption is available in user's locale if not then check for english, else show any available. - if(idAndCaption.getCaptions().get(locale) != null) { - return idAndCaption.getCaptions().get(locale); - } - if(idAndCaption.getCaptions().get("en") != null) { - return idAndCaption.getCaptions().get("en"); - } - return idAndCaption.getCaptions().values().iterator().next(); - } - - public void onMediaDetailLicenceClicked(){ - String url = media.getLicenseUrl(); - if (!StringUtils.isBlank(url) && getActivity() != null) { - Utils.handleWebUrl(getActivity(), Uri.parse(url)); - } else { - viewUtil.showShortToast(getActivity(), getString(R.string.null_url)); - } - } - - public void onMediaDetailCoordinatesClicked(){ - if (media.getCoordinates() != null && getActivity() != null) { - Utils.handleGeoCoordinates(getActivity(), media.getCoordinates()); - } - } - - public void onCopyWikicodeClicked() { - String data = - "[[" + media.getFilename() + "|thumb|" + media.getFallbackDescription() + "]]"; - Utils.copy("wikiCode", data, getContext()); - Timber.d("Generated wikidata copy code: %s", data); - - Toast.makeText(getContext(), getString(R.string.wikicode_copied), Toast.LENGTH_SHORT) - .show(); - } - - /** - * Sends thanks to author if the author is not the user - */ - public void sendThanksToAuthor() { - String fileName = media.getFilename(); - if (TextUtils.isEmpty(fileName)) { - Toast.makeText(getContext(), getString(R.string.error_sending_thanks), - Toast.LENGTH_SHORT).show(); - return; - } - compositeDisposable.add(reviewHelper.getFirstRevisionOfFile(fileName) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(revision -> sendThanks(getContext(), revision))); - } - - /** - * Api call for sending thanks to the author when the author is not the user - * and display toast depending on the result - * @param context context - * @param firstRevision the revision id of the image - */ - @SuppressLint({"CheckResult", "StringFormatInvalid"}) - void sendThanks(Context context, MwQueryPage.Revision firstRevision) { - ViewUtil.showShortToast(context, - context.getString(R.string.send_thank_toast, media.getDisplayTitle())); - - if (firstRevision == null) { - return; - } - - Observable.defer((Callable>) () -> thanksClient.thank( - firstRevision.getRevisionId())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - displayThanksToast(getContext(), result); - }, throwable -> { - if (throwable instanceof InvalidLoginTokenException) { - final String username = sessionManager.getUserName(); - final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener( - getActivity(), - requireActivity().getString(R.string.invalid_login_message), - username - ); - - CommonsApplication.getInstance().clearApplicationData( - requireActivity(), logoutListener); - } else { - Timber.e(throwable); - } - }); - } - - /** - * Method to display toast when api call to thank the author is completed - * @param context context - * @param result true if success, false otherwise - */ - @SuppressLint("StringFormatInvalid") - private void displayThanksToast(final Context context, final boolean result) { - final String message; - final String title; - if (result) { - title = context.getString(R.string.send_thank_success_title); - message = context.getString(R.string.send_thank_success_message, - media.getDisplayTitle()); - } else { - title = context.getString(R.string.send_thank_failure_title); - message = context.getString(R.string.send_thank_failure_message, - media.getDisplayTitle()); - } - - ViewUtil.showShortToast(context, message); - } - - public void onCategoryEditButtonClicked(){ - binding.progressBarEditCategory.setVisibility(VISIBLE); - binding.categoryEditButton.setVisibility(GONE); - getWikiText(); - } - - /** - * Gets WikiText from the server and send it to catgory editor - */ - private void getWikiText() { - compositeDisposable.add(mediaDataExtractor.getCurrentWikiText( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::gotoCategoryEditor, Timber::e)); - } - - /** - * Opens the category editor - * - * @param s WikiText - */ - private void gotoCategoryEditor(final String s) { - binding.categoryEditButton.setVisibility(VISIBLE); - binding.progressBarEditCategory.setVisibility(GONE); - final Fragment categoriesFragment = new UploadCategoriesFragment(); - final Bundle bundle = new Bundle(); - bundle.putParcelable("Existing_Categories", media); - bundle.putString("WikiText", s); - categoriesFragment.setArguments(bundle); - final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); - transaction.replace(R.id.mediaDetailFrameLayout, categoriesFragment); - transaction.addToBackStack(null); - transaction.commit(); - } - - public void onUpdateCoordinatesClicked(){ - goToLocationPickerActivity(); - } - - /** - * Start location picker activity with a request code and get the coordinates from the activity. - */ - private void goToLocationPickerActivity() { - /* - If location is not provided in media this coordinates will act as a placeholder in - location picker activity - */ - double defaultLatitude = 37.773972; - double defaultLongitude = -122.431297; - if (media.getCoordinates() != null) { - defaultLatitude = media.getCoordinates().getLatitude(); - defaultLongitude = media.getCoordinates().getLongitude(); - } else { - if(locationManager.getLastLocation()!=null) { - defaultLatitude = locationManager.getLastLocation().getLatitude(); - defaultLongitude = locationManager.getLastLocation().getLongitude(); - } else { - String[] lastLocation = applicationKvStore.getString(LAST_LOCATION,(defaultLatitude + "," + defaultLongitude)).split(","); - defaultLatitude = Double.parseDouble(lastLocation[0]); - defaultLongitude = Double.parseDouble(lastLocation[1]); - } - } - - - startActivity(new LocationPicker.IntentBuilder() - .defaultLocation(new CameraPosition(defaultLatitude,defaultLongitude,16.0)) - .activityKey("MediaActivity") - .media(media) - .build(getActivity())); - } - - public void onDescriptionEditClicked() { - binding.progressBarEdit.setVisibility(VISIBLE); - binding.descriptionEdit.setVisibility(GONE); - getDescriptionList(); - } - - /** - * Gets descriptions from wikitext - */ - private void getDescriptionList() { - compositeDisposable.add(mediaDataExtractor.getCurrentWikiText( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::extractCaptionDescription, Timber::e)); - } - - /** - * Gets captions and descriptions and merge them according to language code and arranges it in a - * single list. - * Send the list to DescriptionEditActivity - * @param s wikitext - */ - private void extractCaptionDescription(final String s) { - final LinkedHashMap descriptions = getDescriptions(s); - final LinkedHashMap captions = getCaptionsList(); - - final ArrayList descriptionAndCaptions = new ArrayList<>(); - - if(captions.size() >= descriptions.size()) { - for (final Map.Entry mapElement : captions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (descriptions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, - Objects.requireNonNull(descriptions.get(language)), - (String) mapElement.getValue()) - ); - } else { - descriptionAndCaptions.add( - new UploadMediaDetail(language, "", - (String) mapElement.getValue()) - ); - } - } - for (final Map.Entry mapElement : descriptions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (!captions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, - Objects.requireNonNull(descriptions.get(language)), - "") - ); - } - } - } else { - for (final Map.Entry mapElement : descriptions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (captions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, (String) mapElement.getValue(), - Objects.requireNonNull(captions.get(language))) - ); - } else { - descriptionAndCaptions.add( - new UploadMediaDetail(language, (String) mapElement.getValue(), - "") - ); - } - } - for (final Map.Entry mapElement : captions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (!descriptions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, - "", - Objects.requireNonNull(descriptions.get(language))) - ); - } - } - } - final Intent intent = new Intent(requireContext(), DescriptionEditActivity.class); - final Bundle bundle = new Bundle(); - bundle.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, descriptionAndCaptions); - bundle.putString(WIKITEXT, s); - bundle.putString(Prefs.DESCRIPTION_LANGUAGE, applicationKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, "")); - bundle.putParcelable("media", media); - intent.putExtras(bundle); - startActivity(intent); - } - - /** - * Filters descriptions from current wikiText and arranges it in LinkedHashmap according to the - * language code - * @param s wikitext - * @return LinkedHashMap - */ - private LinkedHashMap getDescriptions(String s) { - final Pattern pattern = Pattern.compile("[dD]escription *=(.*?)\n *\\|", Pattern.DOTALL); - final Matcher matcher = pattern.matcher(s); - String description = null; - if (matcher.find()) { - description = matcher.group(); - } - if(description == null){ - return new LinkedHashMap<>(); - } - - final LinkedHashMap descriptionList = new LinkedHashMap<>(); - - int count = 0; // number of "{{" - int startCode = 0; - int endCode = 0; - int startDescription = 0; - int endDescription = 0; - final HashSet allLanguageCodes = new HashSet<>(Arrays.asList("en","es","de","ja","fr","ru","pt","it","zh-hans","zh-hant","ar","ko","id","pl","nl","fa","hi","th","vi","sv","uk","cs","simple","hu","ro","fi","el","he","nb","da","sr","hr","ms","bg","ca","tr","sk","sh","bn","tl","mr","ta","kk","lt","az","bs","sl","sq","arz","zh-yue","ka","te","et","lv","ml","hy","uz","kn","af","nn","mk","gl","sw","eu","ur","ky","gu","bh","sco","ast","is","mn","be","an","km","si","ceb","jv","eo","als","ig","su","be-x-old","la","my","cy","ne","bar","azb","mzn","as","am","so","pa","map-bms","scn","tg","ckb","ga","lb","war","zh-min-nan","nds","fy","vec","pnb","zh-classical","lmo","tt","io","ia","br","hif","mg","wuu","gan","ang","or","oc","yi","ps","tk","ba","sah","fo","nap","vls","sa","ce","qu","ku","min","bcl","ilo","ht","li","wa","vo","nds-nl","pam","new","mai","sn","pms","eml","yo","ha","gn","frr","gd","hsb","cv","lo","os","se","cdo","sd","ksh","bat-smg","bo","nah","xmf","ace","roa-tara","hak","bjn","gv","mt","pfl","szl","bpy","rue","co","diq","sc","rw","vep","lij","kw","fur","pcd","lad","tpi","ext","csb","rm","kab","gom","udm","mhr","glk","za","pdc","om","iu","nv","mi","nrm","tcy","frp","myv","kbp","dsb","zu","ln","mwl","fiu-vro","tum","tet","tn","pnt","stq","nov","ny","xh","crh","lfn","st","pap","ay","zea","bxr","kl","sm","ak","ve","pag","nso","kaa","lez","gag","kv","bm","to","lbe","krc","jam","ss","roa-rup","dv","ie","av","cbk-zam","chy","inh","ug","ch","arc","pih","mrj","kg","rmy","dty","na","ts","xal","wo","fj","tyv","olo","ltg","ff","jbo","haw","ki","chr","sg","atj","sat","ady","ty","lrc","ti","din","gor","lg","rn","bi","cu","kbd","pi","cr","koi","ik","mdf","bug","ee","shn","tw","dz","srn","ks","test","en-x-piglatin","ab")); - for (int i = 0; i < description.length() - 1; i++) { - if (description.startsWith("{{", i)) { - if (count == 0) { - startCode = i; - endCode = description.indexOf("|", i); - startDescription = endCode + 1; - if (description.startsWith("1=", endCode + 1)) { - startDescription += 2; - i += 2; - } - } - i++; - count++; - } else if (description.startsWith("}}", i)) { - count--; - if (count == 0) { - endDescription = i; - final String languageCode = description.substring(startCode + 2, endCode); - final String languageDescription = description.substring(startDescription, endDescription); - if (allLanguageCodes.contains(languageCode)) { - descriptionList.put(languageCode, languageDescription); - } - } - i++; - } - } - return descriptionList; - } - - /** - * Gets list of caption and arranges it in a LinkedHashmap according to the language code - * @return LinkedHashMap - */ - private LinkedHashMap getCaptionsList() { - final LinkedHashMap captionList = new LinkedHashMap<>(); - final Map captions = media.getCaptions(); - for (final Map.Entry map : captions.entrySet()) { - final String language = map.getKey(); - final String languageCaption = map.getValue(); - captionList.put(language, languageCaption); - } - return captionList; - } - - /** - * Get the result from another activity and act accordingly. - * @param requestCode - * @param resultCode - * @param data - */ - @Override - public void onActivityResult(final int requestCode, final int resultCode, - @Nullable final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - if (requestCode == REQUEST_CODE_EDIT_DESCRIPTION && resultCode == RESULT_OK) { - final String updatedWikiText = data.getStringExtra(UPDATED_WIKITEXT); - - try { - compositeDisposable.add(descriptionEditHelper.addDescription(getContext(), media, - updatedWikiText) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - Timber.d("Descriptions 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( - getActivity(), - requireActivity().getString(R.string.invalid_login_message), - username - ); - - CommonsApplication.getInstance().clearApplicationData( - requireActivity(), logoutListener); - } - } - - final ArrayList uploadMediaDetails - = data.getParcelableArrayListExtra(LIST_OF_DESCRIPTION_AND_CAPTION); - - LinkedHashMap updatedCaptions = new LinkedHashMap<>(); - for (UploadMediaDetail mediaDetail: - uploadMediaDetails) { - try { - compositeDisposable.add(descriptionEditHelper.addCaption(getContext(), media, - mediaDetail.getLanguageCode(), mediaDetail.getCaptionText()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - updateCaptions(mediaDetail, updatedCaptions); - Timber.d("Caption is added."); - })); - - } catch (Exception e) { - if (e.getLocalizedMessage().equals(CsrfTokenClient.ANONYMOUS_TOKEN_MESSAGE)) { - final String username = sessionManager.getUserName(); - final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener( - getActivity(), - requireActivity().getString(R.string.invalid_login_message), - username - ); - - CommonsApplication.getInstance().clearApplicationData( - requireActivity(), logoutListener); - } - } - } - binding.progressBarEdit.setVisibility(GONE); - binding.descriptionEdit.setVisibility(VISIBLE); - - } else if (requestCode == REQUEST_CODE_EDIT_DESCRIPTION && resultCode == RESULT_CANCELED) { - binding.progressBarEdit.setVisibility(GONE); - binding.descriptionEdit.setVisibility(VISIBLE); - } - } - - /** - * Adds caption to the map and updates captions - * @param mediaDetail UploadMediaDetail - * @param updatedCaptions updated captionds - */ - private void updateCaptions(UploadMediaDetail mediaDetail, - LinkedHashMap updatedCaptions) { - updatedCaptions.put(mediaDetail.getLanguageCode(), mediaDetail.getCaptionText()); - media.setCaptions(updatedCaptions); - } - - @SuppressLint("StringFormatInvalid") - public void onDeleteButtonClicked(){ - if (AccountUtil.getUserName(getContext()) != null && AccountUtil.getUserName(getContext()).equals(media.getAuthor())) { - final ArrayAdapter languageAdapter = new ArrayAdapter<>(getActivity(), - R.layout.simple_spinner_dropdown_list, reasonList); - final Spinner spinner = new Spinner(getActivity()); - spinner.setLayoutParams( - new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - spinner.setAdapter(languageAdapter); - spinner.setGravity(17); - - AlertDialog dialog = DialogUtil.showAlertDialog(getActivity(), - getString(R.string.nominate_delete), - null, - getString(R.string.about_translate_proceed), - getString(R.string.about_translate_cancel), - () -> onDeleteClicked(spinner), - () -> {}, - spinner, - true); - if (isDeleted) { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - } - } - //Reviewer correct me if i have misunderstood something over here - //But how does this if (delete.getVisibility() == View.VISIBLE) { - // enableDeleteButton(true); makes sense ? - else if (AccountUtil.getUserName(getContext()) != null) { - final EditText input = new EditText(getActivity()); - input.requestFocus(); - AlertDialog d = DialogUtil.showAlertDialog(getActivity(), - null, - getString(R.string.dialog_box_text_nomination, media.getDisplayTitle()), - getString(R.string.ok), - getString(R.string.cancel), - () -> { - String reason = input.getText().toString(); - onDeleteClickeddialogtext(reason); - }, - () -> {}, - input, - true); - input.addTextChangedListener(new TextWatcher() { - private void handleText() { - final Button okButton = d.getButton(AlertDialog.BUTTON_POSITIVE); - if (input.getText().length() == 0 || isDeleted) { - okButton.setEnabled(false); - } else { - okButton.setEnabled(true); - } - } - - @Override - public void afterTextChanged(Editable arg0) { - handleText(); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - }); - d.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - } - } - - @SuppressLint("CheckResult") - private void onDeleteClicked(Spinner spinner) { - applicationKvStore.putBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), true); - enableProgressBar(); - String reason = reasonListEnglishMappings.get(spinner.getSelectedItemPosition()); - String finalReason = reason; - Single resultSingle = reasonBuilder.getReason(media, reason) - .flatMap(reasonString -> deleteHelper.makeDeletion(getContext(), media, finalReason)); - resultSingle - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - if(applicationKvStore.getBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - applicationKvStore.remove(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl())); - callback.nominatingForDeletion(index); - } - }); - } - - @SuppressLint("CheckResult") - private void onDeleteClickeddialogtext(String reason) { - applicationKvStore.putBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), true); - enableProgressBar(); - Single resultSingletext = reasonBuilder.getReason(media, reason) - .flatMap(reasonString -> deleteHelper.makeDeletion(getContext(), media, reason)); - resultSingletext - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - if(applicationKvStore.getBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - applicationKvStore.remove(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl())); - callback.nominatingForDeletion(index); - } - }); - } - - public void onSeeMoreClicked(){ - if (binding.nominatedDeletionBanner.getVisibility() == VISIBLE && getActivity() != null) { - Utils.handleWebUrl(getActivity(), Uri.parse(media.getPageTitle().getMobileUri())); - } - } - - public void onAuthorViewClicked() { - if (media == null || media.getUser() == null) { - return; - } - if (sessionManager.getUserName() == null) { - String userProfileLink = BuildConfig.COMMONS_URL + "/wiki/User:" + media.getUser(); - Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(userProfileLink)); - startActivity(browserIntent); - return; - } - ProfileActivity.startYourself(getActivity(), media.getUser(), !Objects - .equals(sessionManager.getUserName(), media.getUser())); - } - - /** - * Enable Progress Bar and Update delete button text. - */ - private void enableProgressBar() { - binding.progressBarDeletion.setVisibility(VISIBLE); - binding.nominateDeletion.setText("Nominating for Deletion"); - isDeleted = true; - } - - private void rebuildCatList(List categories) { - binding.mediaDetailCategoryContainer.removeAllViews(); - for (String category : categories) { - binding.mediaDetailCategoryContainer.addView(buildCatLabel(sanitise(category), binding.mediaDetailCategoryContainer)); - } - } - - //As per issue #1826(see https://github.com/commons-app/apps-android-commons/issues/1826), some categories come suffixed with strings prefixed with |. As per the discussion - //that was meant for alphabetical sorting of the categories and can be safely removed. - private String sanitise(String category) { - int indexOfPipe = category.indexOf('|'); - if (indexOfPipe != -1) { - //Removed everything after '|' - return category.substring(0, indexOfPipe); - } - return category; - } - - /** - * Add view to depictions obtained also tapping on depictions should open the url - */ - private View buildDepictLabel(String depictionName, String entityId, LinearLayout depictionContainer) { - final View item = LayoutInflater.from(getContext()).inflate(R.layout.detail_category_item, depictionContainer,false); - final TextView textView = item.findViewById(R.id.mediaDetailCategoryItemText); - textView.setText(depictionName); - item.setOnClickListener(view -> { - Intent intent = new Intent(getContext(), WikidataItemDetailsActivity.class); - intent.putExtra("wikidataItemName", depictionName); - intent.putExtra("entityId", entityId); - intent.putExtra("fragment", "MediaDetailFragment"); - getContext().startActivity(intent); - }); - return item; - } - - private View buildCatLabel(final String catName, ViewGroup categoryContainer) { - final View item = LayoutInflater.from(getContext()).inflate(R.layout.detail_category_item, categoryContainer, false); - final TextView textView = item.findViewById(R.id.mediaDetailCategoryItemText); - - textView.setText(catName); - if(!getString(R.string.detail_panel_cats_none).equals(catName)) { - textView.setOnClickListener(view -> { - // Open Category Details page - Intent intent = new Intent(getContext(), CategoryDetailsActivity.class); - intent.putExtra("categoryName", catName); - getContext().startActivity(intent); - }); - } - return item; - } - - /** - * Returns captions for media details - * - * @param media object of class media - * @return caption as string - */ - private String prettyCaption(Media media) { - for (String caption : media.getCaptions().values()) { - if (caption.equals("")) { - return getString(R.string.detail_caption_empty); - } else { - return caption; - } - } - return getString(R.string.detail_caption_empty); - } - - private String prettyDescription(Media media) { - String description = chooseDescription(media); - if (!description.isEmpty()) { - // Remove img tag that sometimes appears as a blue square in the app, - // see https://github.com/commons-app/apps-android-commons/issues/4345 - description = description.replaceAll("[<](/)?img[^>]*[>]", ""); - } - return description.isEmpty() ? getString(R.string.detail_description_empty) - : description; - } - - private String chooseDescription(Media media) { - final Map descriptions = media.getDescriptions(); - final String multilingualDesc = descriptions.get(Locale.getDefault().getLanguage()); - if (multilingualDesc != null) { - return multilingualDesc; - } - for (String description : descriptions.values()) { - return description; - } - return media.getFallbackDescription(); - } - - private String prettyDiscussion(String discussion) { - return discussion.isEmpty() ? getString(R.string.detail_discussion_empty) : discussion; - } - - private String prettyLicense(Media media) { - String licenseKey = media.getLicense(); - Timber.d("Media license is: %s", licenseKey); - if (licenseKey == null || licenseKey.equals("")) { - return getString(R.string.detail_license_empty); - } - return licenseKey; - } - - 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"); - } - - /** - * Returns the coordinates nicely formatted. - * - * @return Coordinates as text. - */ - private String prettyCoordinates(Media media) { - if (media.getCoordinates() == null) { - return getString(R.string.media_detail_coordinates_empty); - } - return media.getCoordinates().getPrettyCoordinateString(); - } - - @Override - public boolean updateCategoryDisplay(List categories) { - if (categories == null) { - return false; - } else { - rebuildCatList(categories); - return true; - } - } - - void showCaptionAndDescription() { - if (binding.dummyCaptionDescriptionContainer.getVisibility() == GONE) { - binding.dummyCaptionDescriptionContainer.setVisibility(VISIBLE); - setUpCaptionAndDescriptionLayout(); - } else { - binding.dummyCaptionDescriptionContainer.setVisibility(GONE); - } - } - - /** - * setUp Caption And Description Layout - */ - private void setUpCaptionAndDescriptionLayout() { - List captions = getCaptions(); - - if (descriptionHtmlCode == null) { - binding.showCaptionsBinding.pbCircular.setVisibility(VISIBLE); - } - - getDescription(); - CaptionListViewAdapter adapter = new CaptionListViewAdapter(captions); - binding.showCaptionsBinding.captionListview.setAdapter(adapter); - } - - /** - * Generate the caption with language - */ - private List getCaptions() { - List captionList = new ArrayList<>(); - Map captions = media.getCaptions(); - AppLanguageLookUpTable appLanguageLookUpTable = new AppLanguageLookUpTable(getContext()); - for (Map.Entry map : captions.entrySet()) { - String language = appLanguageLookUpTable.getLocalizedName(map.getKey()); - String languageCaption = map.getValue(); - captionList.add(new Caption(language, languageCaption)); - } - - if (captionList.size() == 0) { - captionList.add(new Caption("", "No Caption")); - } - return captionList; - } - - private void getDescription() { - compositeDisposable.add(mediaDataExtractor.getHtmlOfPage( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::extractDescription, Timber::e)); - } - - /** - * extract the description from html of imagepage - */ - private void extractDescription(String s) { - String descriptionClassName = ""; - int start = s.indexOf(descriptionClassName) + descriptionClassName.length(); - int end = s.indexOf("", start); - descriptionHtmlCode = ""; - for (int i = start; i < end; i++) { - descriptionHtmlCode = descriptionHtmlCode + s.toCharArray()[i]; - } - - binding.showCaptionsBinding.descriptionWebview - .loadDataWithBaseURL(null, descriptionHtmlCode, "text/html", "utf-8", null); - binding.showCaptionsBinding.pbCircular.setVisibility(GONE); - } - - /** - * Handle back event when fragment when showCaptionAndDescriptionContainer is visible - */ - private void handleBackEvent(View view) { - view.setFocusableInTouchMode(true); - view.requestFocus(); - view.setOnKeyListener(new OnKeyListener() { - @Override - public boolean onKey(View view, int keycode, KeyEvent keyEvent) { - if (keycode == KeyEvent.KEYCODE_BACK) { - if (binding.dummyCaptionDescriptionContainer.getVisibility() == VISIBLE) { - binding.dummyCaptionDescriptionContainer.setVisibility(GONE); - return true; - } - } - return false; - } - }); - - } - - - public interface Callback { - void nominatingForDeletion(int index); - } - - /** - * Called when the image background color is changed. - * You should pass a useable color, not a resource id. - * @param color - */ - public void onImageBackgroundChanged(int color) { - int currentColor = getImageBackgroundColor(); - if (currentColor == color) { - return; - } - - binding.mediaDetailImageView.setBackgroundColor(color); - getImageBackgroundColorPref().edit().putInt(IMAGE_BACKGROUND_COLOR, color).apply(); - } - - private SharedPreferences getImageBackgroundColorPref() { - return getContext().getSharedPreferences(IMAGE_BACKGROUND_COLOR + media.getPageId(), Context.MODE_PRIVATE); - } - - private int getImageBackgroundColor() { - SharedPreferences imageBackgroundColorPref = this.getImageBackgroundColorPref(); - return imageBackgroundColorPref.getInt(IMAGE_BACKGROUND_COLOR, DEFAULT_IMAGE_BACKGROUND_COLOR); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt new file mode 100644 index 000000000..299f9b3be --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt @@ -0,0 +1,2231 @@ +package fr.free.nrw.commons.media + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.viewModels +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.drawee.controller.BaseControllerListener +import com.facebook.drawee.controller.ControllerListener +import com.facebook.drawee.interfaces.DraweeController +import com.facebook.imagepipeline.image.ImageInfo +import com.facebook.imagepipeline.request.ImageRequest +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.CameraPosition +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.CommonsApplication.Companion.instance +import fr.free.nrw.commons.locationpicker.LocationPicker +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.MediaDataExtractor +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.actions.ThanksClient +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException +import fr.free.nrw.commons.auth.getUserName +import fr.free.nrw.commons.category.CATEGORY_NEEDING_CATEGORIES +import fr.free.nrw.commons.category.CATEGORY_UNCATEGORISED +import fr.free.nrw.commons.category.CategoryClient +import fr.free.nrw.commons.category.CategoryDetailsActivity +import fr.free.nrw.commons.category.CategoryEditHelper +import fr.free.nrw.commons.contributions.ContributionsFragment +import fr.free.nrw.commons.coordinates.CoordinateEditHelper +import fr.free.nrw.commons.databinding.FragmentMediaDetailBinding +import fr.free.nrw.commons.delete.DeleteHelper +import fr.free.nrw.commons.delete.ReasonBuilder +import fr.free.nrw.commons.description.DescriptionEditActivity +import fr.free.nrw.commons.description.DescriptionEditHelper +import fr.free.nrw.commons.description.EditDescriptionConstants.LIST_OF_DESCRIPTION_AND_CAPTION +import fr.free.nrw.commons.description.EditDescriptionConstants.WIKITEXT +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.language.AppLanguageLookUpTable +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.review.ReviewHelper +import fr.free.nrw.commons.settings.Prefs +import fr.free.nrw.commons.upload.UploadMediaDetail +import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment +import fr.free.nrw.commons.upload.depicts.DepictsFragment +import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment +import fr.free.nrw.commons.utils.DateUtil.getDateStringWithSkeletonPattern +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources +import fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE +import fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction +import fr.free.nrw.commons.utils.PermissionUtils.hasPermission +import fr.free.nrw.commons.utils.ViewUtil.showShortToast +import fr.free.nrw.commons.utils.ViewUtilWrapper +import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage.Revision +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.apache.commons.lang3.StringUtils +import timber.log.Timber +import java.util.Date +import java.util.Locale +import java.util.Objects +import java.util.regex.Matcher +import java.util.regex.Pattern +import javax.inject.Inject +import javax.inject.Named + +class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.Callback { + private var editable: Boolean = false + private var isCategoryImage: Boolean = false + private var detailProvider: MediaDetailProvider? = null + private var index: Int = 0 + private var isDeleted: Boolean = false + private var isWikipediaButtonDisplayed: Boolean = false + private val callback: Callback? = null + + @Inject + lateinit var mediaDetailViewModelFactory: MediaDetailViewModel.MediaDetailViewModelProviderFactory + + @Inject + lateinit var locationManager: LocationServiceManager + + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var mediaDataExtractor: MediaDataExtractor + + @Inject + lateinit var reasonBuilder: ReasonBuilder + + @Inject + lateinit var deleteHelper: DeleteHelper + + @Inject + lateinit var reviewHelper: ReviewHelper + + @Inject + lateinit var categoryEditHelper: CategoryEditHelper + + @Inject + lateinit var coordinateEditHelper: CoordinateEditHelper + + @Inject + lateinit var descriptionEditHelper: DescriptionEditHelper + + @Inject + lateinit var viewUtil: ViewUtilWrapper + + @Inject + lateinit var categoryClient: CategoryClient + + @Inject + lateinit var thanksClient: ThanksClient + + @Inject + @field:Named("default_preferences") + lateinit var applicationKvStore: JsonKvStore + + private val viewModel: MediaDetailViewModel by viewModels { mediaDetailViewModelFactory } + + private var initialListTop: Int = 0 + + private var _binding: FragmentMediaDetailBinding? = null + private val binding get() = _binding!! + + private var descriptionHtmlCode: String? = null + + + private val categoryNames: ArrayList = ArrayList() + + /** + * Depicts is a feature part of Structured data. + * Multiple Depictions can be added for an image just like categories. + * However unlike categories depictions is multi-lingual + * Ex: key: en value: monument + */ + private var imageInfoCache: ImageInfo? = null + private var oldWidthOfImageView: Int = 0 + private var newWidthOfImageView: Int = 0 + private var heightVerifyingBoolean: Boolean = true // helps in maintaining aspect ratio + private var layoutListener: OnGlobalLayoutListener? = null // for layout stuff, only used once! + + //Had to make this class variable, to implement various onClicks, which access the media, + // also I fell why make separate variables when one can serve the purpose + private var media: Media? = null + private lateinit var reasonList: ArrayList + private lateinit var reasonListEnglishMappings: ArrayList + + /** + * Height stores the height of the frame layout as soon as it is initialised + * and updates itself on configuration changes. + * Used to adjust aspect ratio of image when length of the image is too large. + */ + private var frameLayoutHeight: Int = 0 + + /** + * Minimum height of the metadata, in pixels. + * Images with a very narrow aspect ratio will be reduced so that the metadata information + * panel always has at least this height. + */ + private val minimumHeightOfMetadata: Int = 200 + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt("index", index) + outState.putBoolean("editable", editable) + outState.putBoolean("isCategoryImage", isCategoryImage) + outState.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed) + + scrollPosition + outState.putInt("listTop", initialListTop) + } + + private val scrollPosition: Unit + get() { + initialListTop = binding.mediaDetailScrollView.scrollY + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + if (parentFragment != null + && parentFragment is MediaDetailPagerFragment + ) { + detailProvider = + (parentFragment as MediaDetailPagerFragment).mediaDetailProvider + } + if (savedInstanceState != null) { + editable = savedInstanceState.getBoolean("editable") + isCategoryImage = savedInstanceState.getBoolean("isCategoryImage") + isWikipediaButtonDisplayed = savedInstanceState.getBoolean("isWikipediaButtonDisplayed") + index = savedInstanceState.getInt("index") + initialListTop = savedInstanceState.getInt("listTop") + } else { + editable = requireArguments().getBoolean("editable") + isCategoryImage = requireArguments().getBoolean("isCategoryImage") + isWikipediaButtonDisplayed = requireArguments().getBoolean("isWikipediaButtonDisplayed") + index = requireArguments().getInt("index") + initialListTop = 0 + } + + reasonList = ArrayList() + reasonList.add(getString(R.string.deletion_reason_uploaded_by_mistake)) + reasonList.add(getString(R.string.deletion_reason_publicly_visible)) + reasonList.add(getString(R.string.deletion_reason_not_interesting)) + reasonList.add(getString(R.string.deletion_reason_no_longer_want_public)) + reasonList.add(getString(R.string.deletion_reason_bad_for_my_privacy)) + + // Add corresponding mappings in english locale so that we can upload it in deletion request + reasonListEnglishMappings = ArrayList() + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_uploaded_by_mistake) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_publicly_visible) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_not_interesting) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_no_longer_want_public) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_bad_for_my_privacy) + ) + + _binding = FragmentMediaDetailBinding.inflate(inflater, container, false) + val view: View = binding.root + + + Utils.setUnderlinedText(binding.seeMore, R.string.nominated_see_more, requireContext()) + + if (isCategoryImage) { + binding.authorLinearLayout.visibility = View.VISIBLE + } else { + binding.authorLinearLayout.visibility = View.GONE + } + + if (!sessionManager.isUserLoggedIn) { + binding.categoryEditButton.visibility = View.GONE + binding.descriptionEdit.visibility = View.GONE + binding.depictionsEditButton.visibility = View.GONE + } else { + binding.categoryEditButton.visibility = View.VISIBLE + binding.descriptionEdit.visibility = View.VISIBLE + binding.depictionsEditButton.visibility = View.VISIBLE + } + + if (applicationKvStore.getBoolean("login_skipped")) { + binding.nominateDeletion.visibility = View.GONE + binding.coordinateEdit.visibility = View.GONE + } + + handleBackEvent(view) + + //set onCLick listeners + binding.mediaDetailLicense.setOnClickListener { onMediaDetailLicenceClicked() } + binding.mediaDetailCoordinates.setOnClickListener { onMediaDetailCoordinatesClicked() } + binding.sendThanks.setOnClickListener { sendThanksToAuthor() } + binding.dummyCaptionDescriptionContainer.setOnClickListener { showCaptionAndDescription() } + binding.mediaDetailImageView.setOnClickListener { + launchZoomActivity( + binding.mediaDetailImageView + ) + } + binding.categoryEditButton.setOnClickListener { onCategoryEditButtonClicked() } + binding.depictionsEditButton.setOnClickListener { onDepictionsEditButtonClicked() } + binding.seeMore.setOnClickListener { onSeeMoreClicked() } + binding.mediaDetailAuthor.setOnClickListener { onAuthorViewClicked() } + binding.nominateDeletion.setOnClickListener { onDeleteButtonClicked() } + binding.descriptionEdit.setOnClickListener { onDescriptionEditClicked() } + binding.coordinateEdit.setOnClickListener { onUpdateCoordinatesClicked() } + binding.copyWikicode.setOnClickListener { onCopyWikicodeClicked() } + + binding.fileUsagesComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) darkColorScheme( + primary = colorResource(R.color.primaryDarkColor), + surface = colorResource(R.color.main_background_dark), + background = colorResource(R.color.main_background_dark) + ) else lightColorScheme( + primary = colorResource(R.color.primaryColor), + surface = colorResource(R.color.main_background_light), + background = colorResource(R.color.main_background_light) + ) + ) { + + val commonsContainerState by viewModel.commonsContainerState.collectAsState() + val globalContainerState by viewModel.globalContainerState.collectAsState() + + Surface { + Column { + Text( + text = stringResource(R.string.file_usages_container_heading), + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + FileUsagesContainer( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + commonsContainerState = commonsContainerState, + globalContainerState = globalContainerState + ) + } + } + + + } + } + } + + /** + * Gets the height of the frame layout as soon as the view is ready and updates aspect ratio + * of the picture. + */ + view.post { + frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight + updateAspectRatio(binding.mediaDetailScrollView.width) + } + + return view + } + + fun launchZoomActivity(view: View) { + val hasPermission: Boolean = hasPermission(requireActivity(), PERMISSIONS_STORAGE) + if (hasPermission) { + launchZoomActivityAfterPermissionCheck(view) + } else { + checkPermissionsAndPerformAction( + requireActivity(), + { + launchZoomActivityAfterPermissionCheck(view) + }, + R.string.storage_permission_title, + R.string.read_storage_permission_rationale, + *PERMISSIONS_STORAGE + ) + } + } + + private fun fetchFileUsages(fileName: String) { + if (viewModel.commonsContainerState.value == MediaDetailViewModel.FileUsagesContainerState.Initial) { + viewModel.loadFileUsagesCommons(fileName) + } + + if (viewModel.globalContainerState.value == MediaDetailViewModel.FileUsagesContainerState.Initial) { + viewModel.loadGlobalFileUsages(fileName) + } + } + + /** + * launch zoom acitivity after permission check + * @param view as ImageView + */ + private fun launchZoomActivityAfterPermissionCheck(view: View) { + if (media!!.imageUrl != null) { + val ctx: Context = view.context + val zoomableIntent = Intent(ctx, ZoomableActivity::class.java) + zoomableIntent.setData(Uri.parse(media!!.imageUrl)) + zoomableIntent.putExtra( + ZoomableActivity.ZoomableActivityConstants.ORIGIN, "MediaDetails" + ) + + val backgroundColor: Int = imageBackgroundColor + if (backgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { + zoomableIntent.putExtra( + ZoomableActivity.ZoomableActivityConstants.PHOTO_BACKGROUND_COLOR, + backgroundColor + ) + } + + ctx.startActivity( + zoomableIntent + ) + } + } + + override fun onResume() { + super.onResume() + if (parentFragment != null && requireParentFragment().parentFragment != null) { + // Added a check because, not necessarily, the parent fragment + // will have a parent fragment, say in the case when MediaDetailPagerFragment + // is directly started by the CategoryImagesActivity + if (parentFragment is ContributionsFragment) { + (((parentFragment as ContributionsFragment) + .parentFragment) as ContributionsFragment).binding.cardViewNearby.visibility = + View.GONE + } + } + // detail provider is null when fragment is shown in review activity + media = if (detailProvider != null) { + detailProvider!!.getMediaAtPosition(index) + } else { + requireArguments().getParcelable("media") + } + + if (media != null && applicationKvStore.getBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl + ), false + ) + ) { + enableProgressBar() + } + + if (getUserName(requireContext()) != null && media != null && getUserName( + requireContext() + ) == media!!.author + ) { + binding.sendThanks.visibility = View.GONE + } else { + binding.sendThanks.visibility = View.VISIBLE + } + + binding.mediaDetailScrollView.viewTreeObserver.addOnGlobalLayoutListener( + object : OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (context == null) { + return + } + binding.mediaDetailScrollView.viewTreeObserver.removeOnGlobalLayoutListener( + this + ) + oldWidthOfImageView = binding.mediaDetailScrollView.width + if (media != null) { + displayMediaDetails() + fetchFileUsages(media?.filename!!) + } + } + } + ) + binding.progressBarEdit.visibility = View.GONE + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + binding.mediaDetailScrollView.viewTreeObserver.addOnGlobalLayoutListener( + object : OnGlobalLayoutListener { + override fun onGlobalLayout() { + /** + * We update the height of the frame layout as the configuration changes. + */ + binding.mediaDetailFrameLayout.post { + frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight + updateAspectRatio(binding.mediaDetailScrollView.width) + } + if (binding.mediaDetailScrollView.width != oldWidthOfImageView) { + if (newWidthOfImageView == 0) { + newWidthOfImageView = binding.mediaDetailScrollView.width + updateAspectRatio(newWidthOfImageView) + } + binding.mediaDetailScrollView.viewTreeObserver.removeOnGlobalLayoutListener( + this + ) + } + } + } + ) + // Ensuring correct aspect ratio for landscape mode + if (heightVerifyingBoolean) { + updateAspectRatio(newWidthOfImageView) + heightVerifyingBoolean = false + } else { + updateAspectRatio(oldWidthOfImageView) + heightVerifyingBoolean = true + } + } + + private fun displayMediaDetails() { + setTextFields(media!!) + compositeDisposable.addAll( + mediaDataExtractor.refresh(media!!) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { media: Media -> onMediaRefreshed(media) }, + { t: Throwable? -> Timber.e(t) }), + mediaDataExtractor.getCurrentWikiText( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String? -> updateCategoryList(s!!) }, + { t: Throwable? -> Timber.e(t) }), + mediaDataExtractor.checkDeletionRequestExists(media!!) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { deletionPageExists: Boolean -> onDeletionPageExists(deletionPageExists) }, + { t: Throwable? -> Timber.e(t) }), + mediaDataExtractor.fetchDiscussion(media!!) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { discussion: String -> onDiscussionLoaded(discussion) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + private fun onMediaRefreshed(media: Media) { + media.categories = this.media!!.categories + this.media = media + setTextFields(media) + compositeDisposable.addAll( + mediaDataExtractor.fetchDepictionIdsAndLabels(media) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { idAndCaptions: List -> onDepictionsLoaded(idAndCaptions) }, + { t: Throwable? -> Timber.e(t) }) + ) + // compositeDisposable.add(disposable); + } + + private fun onDiscussionLoaded(discussion: String) { + binding.mediaDetailDisc.text = prettyDiscussion(discussion.trim { it <= ' ' }) + } + + private fun onDeletionPageExists(deletionPageExists: Boolean) { + if (getUserName(requireContext()) == null && getUserName(requireContext()) != media!!.author) { + binding.nominateDeletion.visibility = View.GONE + binding.nominatedDeletionBanner.visibility = View.GONE + } else if (deletionPageExists) { + if (applicationKvStore.getBoolean( + String.format(NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl), false + ) + ) { + applicationKvStore.remove( + String.format(NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl) + ) + binding.progressBarDeletion.visibility = View.GONE + } + binding.nominateDeletion.visibility = View.GONE + + binding.nominatedDeletionBanner.visibility = View.VISIBLE + } else if (!isCategoryImage) { + binding.nominateDeletion.visibility = View.VISIBLE + binding.nominatedDeletionBanner.visibility = View.GONE + } + } + + private fun onDepictionsLoaded(idAndCaptions: List) { + binding.depictsLayout.visibility = + if (idAndCaptions.isEmpty()) View.GONE else View.VISIBLE + binding.depictionsEditButton.visibility = + if (idAndCaptions.isEmpty()) View.GONE else View.VISIBLE + buildDepictionList(idAndCaptions) + } + + /** + * By clicking on the edit depictions button, it will send user to depict fragment + */ + fun onDepictionsEditButtonClicked() { + binding.mediaDetailDepictionContainer.removeAllViews() + binding.depictionsEditButton.visibility = View.GONE + val depictsFragment: Fragment = DepictsFragment() + val bundle = Bundle() + bundle.putParcelable("Existing_Depicts", media) + depictsFragment.arguments = bundle + val transaction: FragmentTransaction = childFragmentManager.beginTransaction() + transaction.replace(R.id.mediaDetailFrameLayout, depictsFragment) + transaction.addToBackStack(null) + transaction.commit() + } + + /** + * The imageSpacer is Basically a transparent overlay for the SimpleDraweeView + * which holds the image to be displayed( moreover this image is out of + * the scroll view ) + * + * + * If the image is sufficiently large i.e. the image height extends the view height, we reduce + * the height and change the width to maintain the aspect ratio, otherwise image takes up the + * total possible width and height is adjusted accordingly. + * + * @param scrollWidth the current width of the scrollView + */ + private fun updateAspectRatio(scrollWidth: Int) { + if (imageInfoCache != null) { + var finalHeight: Int = (scrollWidth * imageInfoCache!!.height) / imageInfoCache!!.width + val params: ViewGroup.LayoutParams = binding.mediaDetailImageView.layoutParams + val spacerParams: ViewGroup.LayoutParams = + binding.mediaDetailImageViewSpacer.layoutParams + params.width = scrollWidth + if (finalHeight > frameLayoutHeight - minimumHeightOfMetadata) { + // Adjust the height and width of image. + + val temp: Int = frameLayoutHeight - minimumHeightOfMetadata + params.width = (scrollWidth * temp) / finalHeight + finalHeight = temp + } + params.height = finalHeight + spacerParams.height = finalHeight + binding.mediaDetailImageView.layoutParams = params + binding.mediaDetailImageViewSpacer.layoutParams = spacerParams + } + } + + private val aspectRatioListener: ControllerListener = + object : BaseControllerListener() { + override fun onIntermediateImageSet(id: String, imageInfo: ImageInfo?) { + imageInfoCache = imageInfo + updateAspectRatio(binding.mediaDetailScrollView.width) + } + + override fun onFinalImageSet( + id: String, + imageInfo: ImageInfo?, + animatable: Animatable? + ) { + imageInfoCache = imageInfo + updateAspectRatio(binding.mediaDetailScrollView.width) + } + } + + /** + * Uses two image sources. + * - low resolution thumbnail is shown initially + * - when the high resolution image is available, it replaces the low resolution image + */ + private fun setupImageView() { + val imageBackgroundColor: Int = imageBackgroundColor + if (imageBackgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { + binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) + } + + binding.mediaDetailImageView.hierarchy.setPlaceholderImage(R.drawable.image_placeholder) + binding.mediaDetailImageView.hierarchy.setFailureImage(R.drawable.image_placeholder) + + val controller: DraweeController = Fresco.newDraweeControllerBuilder() + .setLowResImageRequest(ImageRequest.fromUri(if (media != null) media!!.thumbUrl else null)) + .setRetainImageOnFailure(true) + .setImageRequest(ImageRequest.fromUri(if (media != null) media!!.imageUrl else null)) + .setControllerListener(aspectRatioListener) + .setOldController(binding.mediaDetailImageView.controller) + .build() + binding.mediaDetailImageView.controller = controller + } + + private fun updateToDoWarning() { + var toDoMessage = "" + var toDoNeeded = false + var categoriesPresent: Boolean = + if (media!!.categories == null) false else (media!!.categories!!.isNotEmpty()) + + // Check if the presented category is about need of category + if (categoriesPresent) { + for (category: String in media!!.categories!!) { + if (category.lowercase().contains(CATEGORY_NEEDING_CATEGORIES) || + category.lowercase().contains(CATEGORY_UNCATEGORISED) + ) { + categoriesPresent = false + } + break + } + } + if (!categoriesPresent) { + toDoNeeded = true + toDoMessage += getString(R.string.missing_category) + } + if (isWikipediaButtonDisplayed) { + toDoNeeded = true + toDoMessage += if ((toDoMessage.isEmpty())) "" else "\n" + getString(R.string.missing_article) + } + + if (toDoNeeded) { + toDoMessage = getString(R.string.todo_improve) + "\n" + toDoMessage + binding.toDoLayout.visibility = View.VISIBLE + binding.toDoReason.text = toDoMessage + } else { + binding.toDoLayout.visibility = View.GONE + } + } + + override fun onDestroyView() { + if (layoutListener != null && view != null) { + requireView().viewTreeObserver.removeGlobalOnLayoutListener(layoutListener) // old Android was on crack. CRACK IS WHACK + layoutListener = null + } + + compositeDisposable.clear() + + super.onDestroyView() + } + + private fun setTextFields(media: Media) { + setupImageView() + binding.mediaDetailTitle.text = media.displayTitle + binding.mediaDetailDesc.setHtmlText(prettyDescription(media)) + binding.mediaDetailLicense.text = prettyLicense(media) + binding.mediaDetailCoordinates.text = prettyCoordinates(media) + binding.mediaDetailuploadeddate.text = prettyUploadedDate(media) + if (prettyCaption(media) == requireContext().getString(R.string.detail_caption_empty)) { + binding.captionLayout.visibility = View.GONE + } else { + binding.mediaDetailCaption.text = prettyCaption(media) + } + + categoryNames.clear() + categoryNames.addAll(media.categories!!) + + if (media.author == null || media.author == "") { + binding.authorLinearLayout.visibility = View.GONE + } else { + binding.mediaDetailAuthor.text = media.author + } + } + + /** + * Gets new categories from the WikiText and updates it on the UI + * + * @param s WikiText + */ + private fun updateCategoryList(s: String) { + val allCategories: MutableList = ArrayList() + var i: Int = s.indexOf("[[Category:") + while (i != -1) { + val category: String = s.substring(i + 11, s.indexOf("]]", i)) + allCategories.add(category) + i = s.indexOf("]]", i) + i = s.indexOf("[[Category:", i) + } + media!!.categories = allCategories + if (allCategories.isEmpty()) { + // Stick in a filler element. + allCategories.add(getString(R.string.detail_panel_cats_none)) + } + if (sessionManager.isUserLoggedIn) { + binding.categoryEditButton.visibility = View.VISIBLE + } + rebuildCatList(allCategories) + } + + /** + * Updates the categories + */ + fun updateCategories() { + val allCategories: MutableList = ArrayList( + media?.addedCategories!! + ) + media!!.categories = allCategories + if (allCategories.isEmpty()) { + // Stick in a filler element. + allCategories.add(getString(R.string.detail_panel_cats_none)) + } + + rebuildCatList(allCategories) + } + + /** + * Populates media details fragment with depiction list + * @param idAndCaptions + */ + private fun buildDepictionList(idAndCaptions: List) { + binding.mediaDetailDepictionContainer.removeAllViews() + val locale: String = Locale.getDefault().language + for (idAndCaption: IdAndCaptions in idAndCaptions) { + binding.mediaDetailDepictionContainer.addView( + buildDepictLabel( + getDepictionCaption(idAndCaption, locale), + idAndCaption.id, + binding.mediaDetailDepictionContainer + ) + ) + } + } + + private fun getDepictionCaption(idAndCaption: IdAndCaptions, locale: String): String? { + // Check if the Depiction Caption is available in user's locale + // if not then check for english, else show any available. + if (idAndCaption.captions[locale] != null) { + return idAndCaption.captions[locale] + } + if (idAndCaption.captions["en"] != null) { + return idAndCaption.captions["en"] + } + return idAndCaption.captions.values.iterator().next() + } + + private fun onMediaDetailLicenceClicked() { + val url: String? = media!!.licenseUrl + if (!StringUtils.isBlank(url) && activity != null) { + Utils.handleWebUrl(activity, Uri.parse(url)) + } else { + viewUtil.showShortToast(requireActivity(), getString(R.string.null_url)) + } + } + + private fun onMediaDetailCoordinatesClicked() { + if (media!!.coordinates != null && activity != null) { + Utils.handleGeoCoordinates(activity, media!!.coordinates) + } + } + + private fun onCopyWikicodeClicked() { + val data: String = + "[[" + media!!.filename + "|thumb|" + media!!.fallbackDescription + "]]" + Utils.copy("wikiCode", data, context) + Timber.d("Generated wikidata copy code: %s", data) + + Toast.makeText(context, getString(R.string.wikicode_copied), Toast.LENGTH_SHORT) + .show() + } + + /** + * Sends thanks to author if the author is not the user + */ + private fun sendThanksToAuthor() { + val fileName: String? = media!!.filename + if (TextUtils.isEmpty(fileName)) { + Toast.makeText( + context, getString(R.string.error_sending_thanks), + Toast.LENGTH_SHORT + ).show() + return + } + compositeDisposable.add( + reviewHelper.getFirstRevisionOfFile(fileName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { revision: Revision? -> + sendThanks( + requireContext(), revision + ) + } + ) + } + + /** + * Api call for sending thanks to the author when the author is not the user + * and display toast depending on the result + * @param context context + * @param firstRevision the revision id of the image + */ + @SuppressLint("CheckResult", "StringFormatInvalid") + fun sendThanks(context: Context, firstRevision: Revision?) { + showShortToast( + context, + context.getString(R.string.send_thank_toast, media!!.displayTitle) + ) + + if (firstRevision == null) { + return + } + + Observable.defer { + thanksClient.thank( + firstRevision.revisionId() + ) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { result: Boolean -> + displayThanksToast( + requireContext(), result + ) + }, + { throwable: Throwable? -> + if (throwable is InvalidLoginTokenException) { + val username: String? = sessionManager.userName + val logoutListener: CommonsApplication.BaseLogoutListener = + CommonsApplication.BaseLogoutListener( + requireActivity(), + requireActivity().getString(R.string.invalid_login_message), + username + ) + + instance.clearApplicationData( + requireActivity(), logoutListener + ) + } else { + Timber.e(throwable) + } + }) + } + + /** + * Method to display toast when api call to thank the author is completed + * @param context context + * @param result true if success, false otherwise + */ + @SuppressLint("StringFormatInvalid") + private fun displayThanksToast(context: Context, result: Boolean) { + val message: String = if (result) { + context.getString( + R.string.send_thank_success_message, + media!!.displayTitle + ) + } else { + context.getString( + R.string.send_thank_failure_message, + media!!.displayTitle + ) + } + + showShortToast(context, message) + } + + fun onCategoryEditButtonClicked() { + binding.progressBarEditCategory.visibility = View.VISIBLE + binding.categoryEditButton.visibility = View.GONE + wikiText + } + + private val wikiText: Unit + /** + * Gets WikiText from the server and send it to catgory editor + */ + get() { + compositeDisposable.add( + mediaDataExtractor.getCurrentWikiText( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String? -> gotoCategoryEditor(s!!) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + /** + * Opens the category editor + * + * @param s WikiText + */ + private fun gotoCategoryEditor(s: String) { + binding.categoryEditButton.visibility = View.VISIBLE + binding.progressBarEditCategory.visibility = View.GONE + val categoriesFragment: Fragment = UploadCategoriesFragment() + val bundle = Bundle() + bundle.putParcelable("Existing_Categories", media) + bundle.putString("WikiText", s) + categoriesFragment.arguments = bundle + val transaction: FragmentTransaction = childFragmentManager.beginTransaction() + transaction.replace(R.id.mediaDetailFrameLayout, categoriesFragment) + transaction.addToBackStack(null) + transaction.commit() + } + + fun onUpdateCoordinatesClicked() { + goToLocationPickerActivity() + } + + /** + * Start location picker activity with a request code and get the coordinates from the activity. + */ + private fun goToLocationPickerActivity() { + /* + If location is not provided in media this coordinates will act as a placeholder in + location picker activity + */ + var defaultLatitude = 37.773972 + var defaultLongitude: Double = -122.431297 + if (media!!.coordinates != null) { + defaultLatitude = media!!.coordinates!!.latitude + defaultLongitude = media!!.coordinates!!.longitude + } else { + if (locationManager.getLastLocation() != null) { + defaultLatitude = locationManager.getLastLocation()!!.latitude + defaultLongitude = locationManager.getLastLocation()!!.longitude + } else { + val lastLocation: Array? = applicationKvStore.getString( + UploadMediaDetailFragment.LAST_LOCATION, + ("$defaultLatitude,$defaultLongitude") + )?.split(",".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() + + if (lastLocation != null) { + defaultLatitude = lastLocation[0].toDouble() + defaultLongitude = lastLocation[1].toDouble() + } + } + } + + + startActivity( + LocationPicker.IntentBuilder() + .defaultLocation(CameraPosition(defaultLatitude, defaultLongitude, 16.0)) + .activityKey("MediaActivity") + .media(media!!) + .build(requireActivity()) + ) + } + + fun onDescriptionEditClicked() { + binding.progressBarEdit.visibility = View.VISIBLE + binding.descriptionEdit.visibility = View.GONE + descriptionList + } + + private val descriptionList: Unit + /** + * Gets descriptions from wikitext + */ + get() { + compositeDisposable.add( + mediaDataExtractor.getCurrentWikiText( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String? -> extractCaptionDescription(s!!) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + /** + * Gets captions and descriptions and merge them according to language code and arranges it in a + * single list. + * Send the list to DescriptionEditActivity + * @param s wikitext + */ + private fun extractCaptionDescription(s: String) { + val descriptions: LinkedHashMap = getDescriptions(s) + val captions: LinkedHashMap = captionsList + + val descriptionAndCaptions: ArrayList = ArrayList() + + if (captions.size >= descriptions.size) { + for (mapElement: Map.Entry<*, *> in captions.entries) { + val language: String = mapElement.key as String + if (descriptions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, + Objects.requireNonNull(descriptions[language]!!), + (mapElement.value as String?)!! + ) + ) + } else { + descriptionAndCaptions.add( + UploadMediaDetail( + language, "", + (mapElement.value as String?)!! + ) + ) + } + } + for (mapElement: Map.Entry<*, *> in descriptions.entries) { + val language: String = mapElement.key as String + if (!captions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, + Objects.requireNonNull(descriptions[language]!!), + "" + ) + ) + } + } + } else { + for (mapElement: Map.Entry<*, *> in descriptions.entries) { + val language: String = mapElement.key as String + if (captions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, (mapElement.value as String?)!!, + Objects.requireNonNull(captions[language]!!) + ) + ) + } else { + descriptionAndCaptions.add( + UploadMediaDetail( + language, (mapElement.value as String?)!!, + "" + ) + ) + } + } + for (mapElement: Map.Entry<*, *> in captions.entries) { + val language: String = mapElement.key as String + if (!descriptions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, + "", + Objects.requireNonNull(descriptions[language]!!) + ) + ) + } + } + } + val intent = Intent(requireContext(), DescriptionEditActivity::class.java) + val bundle = Bundle() + bundle.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, descriptionAndCaptions) + bundle.putString(WIKITEXT, s) + bundle.putString( + Prefs.DESCRIPTION_LANGUAGE, + applicationKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, "") + ) + bundle.putParcelable("media", media) + intent.putExtras(bundle) + startActivity(intent) + } + + /** + * Filters descriptions from current wikiText and arranges it in LinkedHashmap according to the + * language code + * @param s wikitext + * @return LinkedHashMap,Description> + */ + private fun getDescriptions(s: String): LinkedHashMap { + val pattern: Pattern = Pattern.compile("[dD]escription *=(.*?)\n *\\|", Pattern.DOTALL) + val matcher: Matcher = pattern.matcher(s) + var description: String? = null + if (matcher.find()) { + description = matcher.group() + } + if (description == null) { + return LinkedHashMap() + } + + val descriptionList: LinkedHashMap = LinkedHashMap() + + var count = 0 // number of "{{" + var startCode = 0 + var endCode = 0 + var startDescription = 0 + var endDescription: Int + val allLanguageCodes: HashSet = HashSet( + mutableListOf( + "en", + "es", + "de", + "ja", + "fr", + "ru", + "pt", + "it", + "zh-hans", + "zh-hant", + "ar", + "ko", + "id", + "pl", + "nl", + "fa", + "hi", + "th", + "vi", + "sv", + "uk", + "cs", + "simple", + "hu", + "ro", + "fi", + "el", + "he", + "nb", + "da", + "sr", + "hr", + "ms", + "bg", + "ca", + "tr", + "sk", + "sh", + "bn", + "tl", + "mr", + "ta", + "kk", + "lt", + "az", + "bs", + "sl", + "sq", + "arz", + "zh-yue", + "ka", + "te", + "et", + "lv", + "ml", + "hy", + "uz", + "kn", + "af", + "nn", + "mk", + "gl", + "sw", + "eu", + "ur", + "ky", + "gu", + "bh", + "sco", + "ast", + "is", + "mn", + "be", + "an", + "km", + "si", + "ceb", + "jv", + "eo", + "als", + "ig", + "su", + "be-x-old", + "la", + "my", + "cy", + "ne", + "bar", + "azb", + "mzn", + "as", + "am", + "so", + "pa", + "map-bms", + "scn", + "tg", + "ckb", + "ga", + "lb", + "war", + "zh-min-nan", + "nds", + "fy", + "vec", + "pnb", + "zh-classical", + "lmo", + "tt", + "io", + "ia", + "br", + "hif", + "mg", + "wuu", + "gan", + "ang", + "or", + "oc", + "yi", + "ps", + "tk", + "ba", + "sah", + "fo", + "nap", + "vls", + "sa", + "ce", + "qu", + "ku", + "min", + "bcl", + "ilo", + "ht", + "li", + "wa", + "vo", + "nds-nl", + "pam", + "new", + "mai", + "sn", + "pms", + "eml", + "yo", + "ha", + "gn", + "frr", + "gd", + "hsb", + "cv", + "lo", + "os", + "se", + "cdo", + "sd", + "ksh", + "bat-smg", + "bo", + "nah", + "xmf", + "ace", + "roa-tara", + "hak", + "bjn", + "gv", + "mt", + "pfl", + "szl", + "bpy", + "rue", + "co", + "diq", + "sc", + "rw", + "vep", + "lij", + "kw", + "fur", + "pcd", + "lad", + "tpi", + "ext", + "csb", + "rm", + "kab", + "gom", + "udm", + "mhr", + "glk", + "za", + "pdc", + "om", + "iu", + "nv", + "mi", + "nrm", + "tcy", + "frp", + "myv", + "kbp", + "dsb", + "zu", + "ln", + "mwl", + "fiu-vro", + "tum", + "tet", + "tn", + "pnt", + "stq", + "nov", + "ny", + "xh", + "crh", + "lfn", + "st", + "pap", + "ay", + "zea", + "bxr", + "kl", + "sm", + "ak", + "ve", + "pag", + "nso", + "kaa", + "lez", + "gag", + "kv", + "bm", + "to", + "lbe", + "krc", + "jam", + "ss", + "roa-rup", + "dv", + "ie", + "av", + "cbk-zam", + "chy", + "inh", + "ug", + "ch", + "arc", + "pih", + "mrj", + "kg", + "rmy", + "dty", + "na", + "ts", + "xal", + "wo", + "fj", + "tyv", + "olo", + "ltg", + "ff", + "jbo", + "haw", + "ki", + "chr", + "sg", + "atj", + "sat", + "ady", + "ty", + "lrc", + "ti", + "din", + "gor", + "lg", + "rn", + "bi", + "cu", + "kbd", + "pi", + "cr", + "koi", + "ik", + "mdf", + "bug", + "ee", + "shn", + "tw", + "dz", + "srn", + "ks", + "test", + "en-x-piglatin", + "ab" + ) + ) + var i = 0 + while (i < description.length - 1) { + if (description.startsWith("{{", i)) { + if (count == 0) { + startCode = i + endCode = description.indexOf("|", i) + startDescription = endCode + 1 + if (description.startsWith("1=", endCode + 1)) { + startDescription += 2 + i += 2 + } + } + i++ + count++ + } else if (description.startsWith("}}", i)) { + count-- + if (count == 0) { + endDescription = i + val languageCode: String = description.substring(startCode + 2, endCode) + val languageDescription: String = + description.substring(startDescription, endDescription) + if (allLanguageCodes.contains(languageCode)) { + descriptionList[languageCode] = languageDescription + } + } + i++ + } + i++ + } + return descriptionList + } + + private val captionsList: LinkedHashMap + /** + * Gets list of caption and arranges it in a LinkedHashmap according to the language code + * @return LinkedHashMap,Caption> + */ + get() { + val captionList: LinkedHashMap = + LinkedHashMap() + val captions: Map = media!!.captions + for (map: Map.Entry in captions.entries) { + val language: String = map.key + val languageCaption: String = map.value + captionList[language] = languageCaption + } + return captionList + } + + /** + * Adds caption to the map and updates captions + * @param mediaDetail UploadMediaDetail + * @param updatedCaptions updated captionds + */ + private fun updateCaptions( + mediaDetail: UploadMediaDetail, + updatedCaptions: MutableMap + ) { + updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText + media!!.captions = updatedCaptions + } + + @SuppressLint("StringFormatInvalid") + fun onDeleteButtonClicked() { + if (getUserName(requireContext()) != null && getUserName(requireContext()) == media!!.author) { + val languageAdapter: ArrayAdapter = ArrayAdapter( + requireActivity(), + R.layout.simple_spinner_dropdown_list, reasonList + ) + val spinner = Spinner(activity) + spinner.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + spinner.adapter = languageAdapter + spinner.gravity = 17 + + val dialog: AlertDialog? = showAlertDialog( + requireActivity(), + getString(R.string.nominate_delete), + null, + getString(R.string.about_translate_proceed), + getString(R.string.about_translate_cancel), + { onDeleteClicked(spinner) }, + {}, + spinner + ) + if (isDeleted) { + dialog!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + } else if (getUserName(requireContext()) != null) { + val input = EditText(activity) + input.requestFocus() + val d: AlertDialog? = showAlertDialog( + requireActivity(), + null, + getString(R.string.dialog_box_text_nomination, media!!.displayTitle), + getString(R.string.ok), + getString(R.string.cancel), + { + val reason: String = input.text.toString() + onDeleteClickeddialogtext(reason) + }, + {}, + input + ) + input.addTextChangedListener(object : TextWatcher { + fun handleText() { + val okButton: Button = d!!.getButton(AlertDialog.BUTTON_POSITIVE) + if (input.text.isEmpty() || isDeleted) { + okButton.isEnabled = false + } else { + okButton.isEnabled = true + } + } + + override fun afterTextChanged(arg0: Editable) { + handleText() + } + + override fun beforeTextChanged( + s: CharSequence, + start: Int, + count: Int, + after: Int + ) { + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + } + }) + d!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + } + + @SuppressLint("CheckResult") + private fun onDeleteClicked(spinner: Spinner) { + applicationKvStore.putBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ), true + ) + enableProgressBar() + val reason: String = reasonListEnglishMappings[spinner.selectedItemPosition] + val finalReason: String = reason + val resultSingle: Single = reasonBuilder.getReason(media, reason) + .flatMap { + deleteHelper.makeDeletion( + context, media, finalReason + ) + } + resultSingle + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _ -> + if (applicationKvStore.getBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl + ), false + ) + ) { + applicationKvStore.remove( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ) + ) + callback!!.nominatingForDeletion(index) + } + } + } + + @SuppressLint("CheckResult") + private fun onDeleteClickeddialogtext(reason: String) { + applicationKvStore.putBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ), true + ) + enableProgressBar() + val resultSingletext: Single = reasonBuilder.getReason(media, reason) + .flatMap { _ -> + deleteHelper.makeDeletion( + context, media, reason + ) + } + resultSingletext + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _ -> + if (applicationKvStore.getBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl + ), false + ) + ) { + applicationKvStore.remove( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ) + ) + callback!!.nominatingForDeletion(index) + } + } + } + + private fun onSeeMoreClicked() { + if (binding.nominatedDeletionBanner.visibility == View.VISIBLE && activity != null) { + Utils.handleWebUrl(activity, Uri.parse(media!!.pageTitle.mobileUri)) + } + } + + private fun onAuthorViewClicked() { + if (media == null || media!!.user == null) { + return + } + if (sessionManager.userName == null) { + val userProfileLink: String = BuildConfig.COMMONS_URL + "/wiki/User:" + media!!.user + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(userProfileLink)) + startActivity(browserIntent) + return + } + ProfileActivity.startYourself( + activity, + media!!.user, + sessionManager.userName != media!!.user + ) + } + + /** + * Enable Progress Bar and Update delete button text. + */ + private fun enableProgressBar() { + binding.progressBarDeletion.visibility = View.VISIBLE + binding.nominateDeletion.text = requireContext().getString(R.string.nominate_deletion) + isDeleted = true + } + + private fun rebuildCatList(categories: List) { + binding.mediaDetailCategoryContainer.removeAllViews() + for (category: String in categories) { + binding.mediaDetailCategoryContainer.addView( + buildCatLabel( + sanitise(category), + binding.mediaDetailCategoryContainer + ) + ) + } + } + + //As per issue #1826(see https://github.com/commons-app/apps-android-commons/issues/1826), + // some categories come suffixed with strings prefixed with |. As per the discussion + //that was meant for alphabetical sorting of the categories and can be safely removed. + private fun sanitise(category: String): String { + val indexOfPipe: Int = category.indexOf('|') + if (indexOfPipe != -1) { + //Removed everything after '|' + return category.substring(0, indexOfPipe) + } + return category + } + + /** + * Add view to depictions obtained also tapping on depictions should open the url + */ + private fun buildDepictLabel( + depictionName: String?, + entityId: String, + depictionContainer: LinearLayout + ): View { + val item: View = LayoutInflater.from(context) + .inflate(R.layout.detail_category_item, depictionContainer, false) + val textView: TextView = item.findViewById(R.id.mediaDetailCategoryItemText) + textView.text = depictionName + item.setOnClickListener { + val intent = Intent( + context, + WikidataItemDetailsActivity::class.java + ) + intent.putExtra("wikidataItemName", depictionName) + intent.putExtra("entityId", entityId) + intent.putExtra("fragment", "MediaDetailFragment") + requireContext().startActivity(intent) + } + return item + } + + private fun buildCatLabel(catName: String, categoryContainer: ViewGroup): View { + val item: View = LayoutInflater.from(context) + .inflate(R.layout.detail_category_item, categoryContainer, false) + val textView: TextView = item.findViewById(R.id.mediaDetailCategoryItemText) + + textView.text = catName + if (getString(R.string.detail_panel_cats_none) != catName) { + textView.setOnClickListener { + // Open Category Details page + val intent = Intent(context, CategoryDetailsActivity::class.java) + intent.putExtra("categoryName", catName) + requireContext().startActivity(intent) + } + } + return item + } + + /** + * Returns captions for media details + * + * @param media object of class media + * @return caption as string + */ + private fun prettyCaption(media: Media): String { + for (caption: String in media.captions.values) { + return if (caption == "") { + getString(R.string.detail_caption_empty) + } else { + caption + } + } + return getString(R.string.detail_caption_empty) + } + + private fun prettyDescription(media: Media): String { + var description: String? = chooseDescription(media) + if (description!!.isNotEmpty()) { + // Remove img tag that sometimes appears as a blue square in the app, + // see https://github.com/commons-app/apps-android-commons/issues/4345 + description = description.replace("[<](/)?img[^>]*[>]".toRegex(), "") + } + return description.ifEmpty { getString(R.string.detail_description_empty) } + } + + private fun chooseDescription(media: Media): String? { + val descriptions: Map = media.descriptions + val multilingualDesc: String? = descriptions[Locale.getDefault().language] + if (multilingualDesc != null) { + return multilingualDesc + } + for (description: String in descriptions.values) { + return description + } + return media.fallbackDescription + } + + private fun prettyDiscussion(discussion: String): String { + return discussion.ifEmpty { getString(R.string.detail_discussion_empty) } + } + + private fun prettyLicense(media: Media): String { + val licenseKey: String? = media.license + Timber.d("Media license is: %s", licenseKey) + if (licenseKey == null || licenseKey == "") { + return getString(R.string.detail_license_empty) + } + return licenseKey + } + + private fun prettyUploadedDate(media: Media): String { + val date: Date? = media.dateUploaded + if (date?.toString() == null || date.toString().isEmpty()) { + return "Uploaded date not available" + } + return getDateStringWithSkeletonPattern(date, "dd MMM yyyy") + } + + /** + * Returns the coordinates nicely formatted. + * + * @return Coordinates as text. + */ + private fun prettyCoordinates(media: Media): String { + if (media.coordinates == null) { + return getString(R.string.media_detail_coordinates_empty) + } + return media.coordinates!!.getPrettyCoordinateString() + } + + override fun updateCategoryDisplay(categories: List?): Boolean { + if (categories == null) { + return false + } else { + rebuildCatList(categories) + return true + } + } + + fun showCaptionAndDescription() { + if (binding.dummyCaptionDescriptionContainer.visibility == View.GONE) { + binding.dummyCaptionDescriptionContainer.visibility = View.VISIBLE + setUpCaptionAndDescriptionLayout() + } else { + binding.dummyCaptionDescriptionContainer.visibility = View.GONE + } + } + + /** + * setUp Caption And Description Layout + */ + private fun setUpCaptionAndDescriptionLayout() { + val captions: List = captions + + if (descriptionHtmlCode == null) { + binding.showCaptionsBinding.pbCircular.visibility = View.VISIBLE + } + + description + val adapter = CaptionListViewAdapter(captions) + binding.showCaptionsBinding.captionListview.adapter = adapter + } + + private val captions: List + /** + * Generate the caption with language + */ + get() { + val captionList: MutableList = + ArrayList() + val captions: Map = media!!.captions + val appLanguageLookUpTable = + AppLanguageLookUpTable(requireContext()) + for (map: Map.Entry in captions.entries) { + val language: String? = appLanguageLookUpTable.getLocalizedName(map.key) + val languageCaption: String = map.value + captionList.add(Caption(language, languageCaption)) + } + + if (captionList.size == 0) { + captionList.add(Caption("", "No Caption")) + } + return captionList + } + + private val description: Unit + get() { + compositeDisposable.add( + mediaDataExtractor.getHtmlOfPage( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String -> extractDescription(s) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + /** + * extract the description from html of imagepage + */ + private fun extractDescription(s: String) { + val descriptionClassName = "" + val start: Int = s.indexOf(descriptionClassName) + descriptionClassName.length + val end: Int = s.indexOf("", start) + descriptionHtmlCode = "" + for (i in start until end) { + descriptionHtmlCode += s.toCharArray()[i] + } + + binding.showCaptionsBinding.descriptionWebview + .loadDataWithBaseURL(null, descriptionHtmlCode!!, "text/html", "utf-8", null) + binding.showCaptionsBinding.pbCircular.visibility = View.GONE + } + + /** + * Handle back event when fragment when showCaptionAndDescriptionContainer is visible + */ + private fun handleBackEvent(view: View) { + view.isFocusableInTouchMode = true + view.requestFocus() + view.setOnKeyListener(object : View.OnKeyListener { + override fun onKey(view: View, keycode: Int, keyEvent: KeyEvent): Boolean { + if (keycode == KeyEvent.KEYCODE_BACK) { + if (binding.dummyCaptionDescriptionContainer.visibility == View.VISIBLE) { + binding.dummyCaptionDescriptionContainer.visibility = + View.GONE + return true + } + } + return false + } + }) + } + + + interface Callback { + fun nominatingForDeletion(index: Int) + } + + /** + * Called when the image background color is changed. + * You should pass a useable color, not a resource id. + * @param color + */ + fun onImageBackgroundChanged(color: Int) { + val currentColor: Int = imageBackgroundColor + if (currentColor == color) { + return + } + + binding.mediaDetailImageView.setBackgroundColor(color) + imageBackgroundColorPref.edit().putInt(IMAGE_BACKGROUND_COLOR, color).apply() + } + + private val imageBackgroundColorPref: SharedPreferences + get() = requireContext().getSharedPreferences( + IMAGE_BACKGROUND_COLOR + media!!.pageId, + Context.MODE_PRIVATE + ) + + private val imageBackgroundColor: Int + get() { + val imageBackgroundColorPref: SharedPreferences = + imageBackgroundColorPref + return imageBackgroundColorPref.getInt( + IMAGE_BACKGROUND_COLOR, + DEFAULT_IMAGE_BACKGROUND_COLOR + ) + } + + companion object { + private const val IMAGE_BACKGROUND_COLOR: String = "image_background_color" + const val DEFAULT_IMAGE_BACKGROUND_COLOR: Int = 0 + + @JvmStatic + fun forMedia( + index: Int, + editable: Boolean, + isCategoryImage: Boolean, + isWikipediaButtonDisplayed: Boolean + ): MediaDetailFragment { + val mf = MediaDetailFragment() + val state = Bundle() + state.putBoolean("editable", editable) + state.putBoolean("isCategoryImage", isCategoryImage) + state.putInt("index", index) + state.putInt("listIndex", 0) + state.putInt("listTop", 0) + state.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed) + mf.arguments = state + + return mf + } + + const val NOMINATING_FOR_DELETION_MEDIA: String = "Nominating for deletion %s" + } +} + +@Composable +fun FileUsagesContainer( + modifier: Modifier = Modifier, + commonsContainerState: MediaDetailViewModel.FileUsagesContainerState, + globalContainerState: MediaDetailViewModel.FileUsagesContainerState, +) { + var isCommonsListExpanded by rememberSaveable { mutableStateOf(true) } + var isOtherWikisListExpanded by rememberSaveable { mutableStateOf(true) } + + val uriHandle = LocalUriHandler.current + + Column(modifier = modifier) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + + Text( + text = stringResource(R.string.usages_on_commons_heading), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall + ) + + IconButton(onClick = { + isCommonsListExpanded = !isCommonsListExpanded + }) { + Icon( + imageVector = if (isCommonsListExpanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + } + + if (isCommonsListExpanded) { + when (commonsContainerState) { + MediaDetailViewModel.FileUsagesContainerState.Loading -> { + LinearProgressIndicator() + } + + is MediaDetailViewModel.FileUsagesContainerState.Success -> { + + val data = commonsContainerState.data + + if (data.isNullOrEmpty()) { + ListItem(headlineContent = { + Text( + text = stringResource(R.string.no_usages_found), + style = MaterialTheme.typography.titleSmall + ) + }) + } else { + data.forEach { usage -> + ListItem( + leadingContent = { + Text( + text = stringResource(R.string.bullet_point), + fontWeight = FontWeight.Bold + ) + }, + headlineContent = { + Text( + modifier = Modifier.clickable { + uriHandle.openUri(usage.link!!) + }, + text = usage.title, + style = MaterialTheme.typography.titleSmall.copy( + color = Color(0xFF5A6AEC), + textDecoration = TextDecoration.Underline + ) + ) + }) + } + } + } + + is MediaDetailViewModel.FileUsagesContainerState.Error -> { + ListItem(headlineContent = { + Text( + text = commonsContainerState.errorMessage, + color = Color.Red, + style = MaterialTheme.typography.titleSmall + ) + }) + } + + MediaDetailViewModel.FileUsagesContainerState.Initial -> {} + } + } + + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.usages_on_other_wikis_heading), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall + ) + + IconButton(onClick = { + isOtherWikisListExpanded = !isOtherWikisListExpanded + }) { + Icon( + imageVector = if (isOtherWikisListExpanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + } + + if (isOtherWikisListExpanded) { + when (globalContainerState) { + MediaDetailViewModel.FileUsagesContainerState.Loading -> { + LinearProgressIndicator() + } + + is MediaDetailViewModel.FileUsagesContainerState.Success -> { + + val data = globalContainerState.data + + if (data.isNullOrEmpty()) { + ListItem(headlineContent = { + Text( + text = stringResource(R.string.no_usages_found), + style = MaterialTheme.typography.titleSmall + ) + }) + } else { + data.forEach { usage -> + ListItem( + leadingContent = { + Text( + text = stringResource(R.string.bullet_point), + fontWeight = FontWeight.Bold + ) + }, + headlineContent = { + Text( + text = usage.title, + style = MaterialTheme.typography.titleSmall.copy( + textDecoration = TextDecoration.Underline + ) + ) + }) + } + } + } + + is MediaDetailViewModel.FileUsagesContainerState.Error -> { + ListItem(headlineContent = { + Text( + text = globalContainerState.errorMessage, + color = Color.Red, + style = MaterialTheme.typography.titleSmall + ) + }) + } + + MediaDetailViewModel.FileUsagesContainerState.Initial -> {} + } + } + + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java index f6ef824e7..8f52b1ced 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java @@ -283,6 +283,7 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple builder.setItems(R.array.report_violation_options, (dialog, which) -> { sendReportEmail(media, values[which]); }); + builder.setCancelable(false); builder.show(); } diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailViewModel.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailViewModel.kt new file mode 100644 index 000000000..f02df35c7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailViewModel.kt @@ -0,0 +1,116 @@ +package fr.free.nrw.commons.media + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import fr.free.nrw.commons.R +import fr.free.nrw.commons.fileusages.FileUsagesUiModel +import fr.free.nrw.commons.fileusages.toUiModel +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** + * Show where file is being used on Commons and oher wikis. + */ +class MediaDetailViewModel( + private val applicationContext: Context, + private val okHttpJsonApiClient: OkHttpJsonApiClient +) : + ViewModel() { + + private val _commonsContainerState = + MutableStateFlow(FileUsagesContainerState.Initial) + val commonsContainerState = _commonsContainerState.asStateFlow() + + private val _globalContainerState = + MutableStateFlow(FileUsagesContainerState.Initial) + val globalContainerState = _globalContainerState.asStateFlow() + + fun loadFileUsagesCommons(fileName: String) { + + viewModelScope.launch { + + _commonsContainerState.update { FileUsagesContainerState.Loading } + + try { + val result = + okHttpJsonApiClient.getFileUsagesOnCommons(fileName, 10) + + val data = result?.query?.pages?.first()?.fileUsage?.map { it.toUiModel() } + + _commonsContainerState.update { FileUsagesContainerState.Success(data = data) } + + } catch (e: Exception) { + + _commonsContainerState.update { + FileUsagesContainerState.Error( + errorMessage = applicationContext.getString( + R.string.error_while_loading + ) + ) + } + + Timber.e(e, javaClass.simpleName) + + } + } + + } + + fun loadGlobalFileUsages(fileName: String) { + + viewModelScope.launch { + + _globalContainerState.update { FileUsagesContainerState.Loading } + + try { + val result = okHttpJsonApiClient.getGlobalFileUsages(fileName, 10) + + val data = result?.query?.pages?.first()?.fileUsage?.map { it.toUiModel() } + + _globalContainerState.update { FileUsagesContainerState.Success(data = data) } + + } catch (e: Exception) { + _globalContainerState.update { + FileUsagesContainerState.Error( + errorMessage = applicationContext.getString( + R.string.error_while_loading + ) + ) + } + + Timber.e(e, javaClass.simpleName) + + } + } + + } + + sealed class FileUsagesContainerState { + object Initial : FileUsagesContainerState() + object Loading : FileUsagesContainerState() + data class Success(val data: List?) : FileUsagesContainerState() + data class Error(val errorMessage: String) : FileUsagesContainerState() + } + + class MediaDetailViewModelProviderFactory + @Inject constructor( + private val okHttpJsonApiClient: OkHttpJsonApiClient, + private val applicationContext: Context + ) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MediaDetailViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return MediaDetailViewModel(applicationContext, okHttpJsonApiClient) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt index d08e3048c..3b72982f1 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt @@ -196,6 +196,7 @@ class ZoomableActivity : BaseActivity() { val dialog = Dialog(this) dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) dialog.setContentView(R.layout.full_screen_mode_info_dialog) + dialog.setCancelable(false) (dialog.findViewById(R.id.btn_ok) as Button).setOnClickListener { dialog.dismiss() } dialog.show() } @@ -219,7 +220,7 @@ class ZoomableActivity : BaseActivity() { onSwipe() } } - binding.zoomProgressBar?.let { + binding.zoomProgressBar.let { it.visibility = if (result.status is CallbackStatus.FETCHING) View.VISIBLE else View.GONE } } @@ -234,7 +235,7 @@ class ZoomableActivity : BaseActivity() { sharedPreferences.getBoolean(ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) if (!images.isNullOrEmpty()) { - binding.zoomable!!.setOnTouchListener( + binding.zoomable.setOnTouchListener( object : OnSwipeTouchListener(this) { // Swipe left to view next image in the folder. (if available) override fun onSwipeLeft() { @@ -271,7 +272,7 @@ class ZoomableActivity : BaseActivity() { * Handles down swipe action */ private fun onDownSwiped() { - if (binding.zoomable?.zoomableController?.isIdentity == false) { + if (binding.zoomable.zoomableController?.isIdentity == false) { return } @@ -341,7 +342,7 @@ class ZoomableActivity : BaseActivity() { * Handles up swipe action */ private fun onUpSwiped() { - if (binding.zoomable?.zoomableController?.isIdentity == false) { + if (binding.zoomable.zoomableController?.isIdentity == false) { return } @@ -414,7 +415,7 @@ class ZoomableActivity : BaseActivity() { * Handles right swipe action */ private fun onRightSwiped(showAlreadyActionedImages: Boolean) { - if (binding.zoomable?.zoomableController?.isIdentity == false) { + if (binding.zoomable.zoomableController?.isIdentity == false) { return } @@ -451,7 +452,7 @@ class ZoomableActivity : BaseActivity() { * Handles left swipe action */ private fun onLeftSwiped(showAlreadyActionedImages: Boolean) { - if (binding.zoomable?.zoomableController?.isIdentity == false) { + if (binding.zoomable.zoomableController?.isIdentity == false) { return } @@ -646,7 +647,7 @@ class ZoomableActivity : BaseActivity() { .setProgressBarImage(ProgressBarDrawable()) .setProgressBarImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) .build() - with(binding.zoomable!!) { + with(binding.zoomable) { setHierarchy(hierarchy) setAllowTouchInterceptionWhileZoomed(true) setIsLongpressEnabled(false) @@ -658,10 +659,10 @@ class ZoomableActivity : BaseActivity() { .setUri(imageUri) .setControllerListener(loadingListener) .build() - binding.zoomable!!.controller = controller + binding.zoomable.controller = controller if (photoBackgroundColor != null) { - binding.zoomable!!.setBackgroundColor(photoBackgroundColor!!) + binding.zoomable.setBackgroundColor(photoBackgroundColor!!) } if (!images.isNullOrEmpty()) { diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java deleted file mode 100644 index f810d0480..000000000 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java +++ /dev/null @@ -1,102 +0,0 @@ -package fr.free.nrw.commons.mwapi; - -import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_PREFIX; - -import com.google.gson.Gson; -import fr.free.nrw.commons.category.CategoryItem; -import io.reactivex.Single; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import javax.inject.Inject; -import javax.inject.Named; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage; -import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse; -import timber.log.Timber; - -/** - * Uses the OkHttp library to implement calls to the Commons MediaWiki API to match GPS coordinates - * with nearby Commons categories. Parses the results using GSON to obtain a list of relevant - * categories. Note: that caller is responsible for executing the request() method on a background - * thread. - */ -public class CategoryApi { - - private final OkHttpClient okHttpClient; - private final String commonsBaseUrl; - private final Gson gson; - - @Inject - public CategoryApi(OkHttpClient okHttpClient, Gson gson, - @Named("wikimedia_api_host") String commonsBaseUrl) { - this.okHttpClient = okHttpClient; - this.commonsBaseUrl = commonsBaseUrl; - this.gson = gson; - } - - public Single> request(String coords) { - return Single.fromCallable(() -> { - HttpUrl apiUrl = buildUrl(coords); - Timber.d("URL: %s", apiUrl.toString()); - - Request request = new Request.Builder().get().url(apiUrl).build(); - Response response = okHttpClient.newCall(request).execute(); - ResponseBody body = response.body(); - if (body == null) { - return Collections.emptyList(); - } - - MwQueryResponse apiResponse = gson.fromJson(body.charStream(), MwQueryResponse.class); - Set categories = new LinkedHashSet<>(); - if (apiResponse != null && apiResponse.query() != null && apiResponse.query().pages() != null) { - for (MwQueryPage page : apiResponse.query().pages()) { - if (page.categories() != null) { - for (MwQueryPage.Category category : page.categories()) { - categories.add(new CategoryItem(category.title().replace(CATEGORY_PREFIX, ""), "", "", false)); - } - } - } - } - return new ArrayList<>(categories); - }); - } - - /** - * Builds URL with image coords for MediaWiki API calls - * Example URL: https://commons.wikimedia.org/w/api.php?action=query&prop=categories|coordinates|pageprops&format=json&clshow=!hidden&coprop=type|name|dim|country|region|globe&codistancefrompoint=38.11386944444445|13.356263888888888&generator=geosearch&redirects=&ggscoord=38.11386944444445|1.356263888888888&ggsradius=100&ggslimit=10&ggsnamespace=6&ggsprop=type|name|dim|country|region|globe&ggsprimary=all&formatversion=2 - * - * @param coords Coordinates to build query with - * @return URL for API query - */ - private HttpUrl buildUrl(String coords) { - return HttpUrl - .parse(commonsBaseUrl) - .newBuilder() - .addQueryParameter("action", "query") - .addQueryParameter("prop", "categories|coordinates|pageprops") - .addQueryParameter("format", "json") - .addQueryParameter("clshow", "!hidden") - .addQueryParameter("coprop", "type|name|dim|country|region|globe") - .addQueryParameter("codistancefrompoint", coords) - .addQueryParameter("generator", "geosearch") - .addQueryParameter("ggscoord", coords) - .addQueryParameter("ggsradius", "10000") - .addQueryParameter("ggslimit", "10") - .addQueryParameter("ggsnamespace", "6") - .addQueryParameter("ggsprop", "type|name|dim|country|region|globe") - .addQueryParameter("ggsprimary", "all") - .addQueryParameter("formatversion", "2") - .build(); - } - -} - - - diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt b/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt new file mode 100644 index 000000000..1f8c51187 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt @@ -0,0 +1,83 @@ +package fr.free.nrw.commons.mwapi + +import com.google.gson.Gson +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.category.CATEGORY_PREFIX +import fr.free.nrw.commons.category.CategoryItem +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import io.reactivex.Single +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import javax.inject.Inject + +/** + * Uses the OkHttp library to implement calls to the Commons MediaWiki API to match GPS coordinates + * with nearby Commons categories. Parses the results using GSON to obtain a list of relevant + * categories. Note: that caller is responsible for executing the request() method on a background + * thread. + */ +class CategoryApi @Inject constructor( + private val okHttpClient: OkHttpClient, + private val gson: Gson +) { + private val apiUrl : HttpUrl by lazy { BuildConfig.WIKIMEDIA_API_HOST.toHttpUrlOrNull()!! } + + fun request(coords: String): Single> = Single.fromCallable { + val apiUrl = buildUrl(coords) + Timber.d("URL: %s", apiUrl.toString()) + + val request: Request = Request.Builder().get().url(apiUrl).build() + val response = okHttpClient.newCall(request).execute() + val body = response.body ?: return@fromCallable emptyList() + + val apiResponse = gson.fromJson(body.charStream(), MwQueryResponse::class.java) + val categories: MutableSet = mutableSetOf() + if (apiResponse?.query() != null && apiResponse.query()!!.pages() != null) { + for (page in apiResponse.query()!!.pages()!!) { + if (page.categories() != null) { + for (category in page.categories()!!) { + categories.add( + CategoryItem( + name = category.title().replace(CATEGORY_PREFIX, ""), + description = "", + thumbnail = "", + isSelected = false + ) + ) + } + } + } + } + ArrayList(categories) + } + + /** + * Builds URL with image coords for MediaWiki API calls + * Example URL: https://commons.wikimedia.org/w/api.php?action=query&prop=categories|coordinates|pageprops&format=json&clshow=!hidden&coprop=type|name|dim|country|region|globe&codistancefrompoint=38.11386944444445|13.356263888888888&generator=geosearch&redirects=&ggscoord=38.11386944444445|1.356263888888888&ggsradius=100&ggslimit=10&ggsnamespace=6&ggsprop=type|name|dim|country|region|globe&ggsprimary=all&formatversion=2 + * + * @param coords Coordinates to build query with + * @return URL for API query + */ + private fun buildUrl(coords: String): HttpUrl = apiUrl.newBuilder() + .addQueryParameter("action", "query") + .addQueryParameter("prop", "categories|coordinates|pageprops") + .addQueryParameter("format", "json") + .addQueryParameter("clshow", "!hidden") + .addQueryParameter("coprop", "type|name|dim|country|region|globe") + .addQueryParameter("codistancefrompoint", coords) + .addQueryParameter("generator", "geosearch") + .addQueryParameter("ggscoord", coords) + .addQueryParameter("ggsradius", "10000") + .addQueryParameter("ggslimit", "10") + .addQueryParameter("ggsnamespace", "6") + .addQueryParameter("ggsprop", "type|name|dim|country|region|globe") + .addQueryParameter("ggsprimary", "all") + .addQueryParameter("formatversion", "2") + .build() +} + + + diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java deleted file mode 100644 index 8d6b74231..000000000 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java +++ /dev/null @@ -1,677 +0,0 @@ -package fr.free.nrw.commons.mwapi; - -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LEADERBOARD_END_POINT; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.UPDATE_AVATAR_END_POINT; - -import android.text.TextUtils; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.gson.Gson; -import fr.free.nrw.commons.campaigns.CampaignResponseDTO; -import fr.free.nrw.commons.explore.depictions.DepictsClient; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.nearby.model.ItemsClass; -import fr.free.nrw.commons.nearby.model.NearbyResponse; -import fr.free.nrw.commons.nearby.model.NearbyResultItem; -import fr.free.nrw.commons.nearby.model.PlaceBindings; -import fr.free.nrw.commons.profile.achievements.FeaturedImages; -import fr.free.nrw.commons.profile.achievements.FeedbackResponse; -import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse; -import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse; -import fr.free.nrw.commons.upload.FileUtils; -import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.wikidata.model.GetWikidataEditCountResponse; -import io.reactivex.Observable; -import io.reactivex.Single; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.inject.Inject; -import javax.inject.Singleton; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.jetbrains.annotations.NotNull; -import timber.log.Timber; - -/** - * Test methods in ok http api client - */ -@Singleton -public class OkHttpJsonApiClient { - - private final OkHttpClient okHttpClient; - private final DepictsClient depictsClient; - private final HttpUrl wikiMediaToolforgeUrl; - private final String sparqlQueryUrl; - private final String campaignsUrl; - private final Gson gson; - - - @Inject - public OkHttpJsonApiClient(OkHttpClient okHttpClient, - DepictsClient depictsClient, - HttpUrl wikiMediaToolforgeUrl, - String sparqlQueryUrl, - String campaignsUrl, - Gson gson) { - this.okHttpClient = okHttpClient; - this.depictsClient = depictsClient; - this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl; - this.sparqlQueryUrl = sparqlQueryUrl; - this.campaignsUrl = campaignsUrl; - this.gson = gson; - } - - /** - * The method will gradually calls the leaderboard API and fetches the leaderboard - * - * @param userName username of leaderboard user - * @param duration duration for leaderboard - * @param category category for leaderboard - * @param limit page size limit for list - * @param offset offset for the list - * @return LeaderboardResponse object - */ - @NonNull - public Observable getLeaderboard(String userName, String duration, - String category, String limit, String offset) { - final String fetchLeaderboardUrlTemplate = wikiMediaToolforgeUrl - + LEADERBOARD_END_POINT; - String url = String.format(Locale.ENGLISH, - fetchLeaderboardUrlTemplate, - userName, - duration, - category, - limit, - offset); - HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); - urlBuilder.addQueryParameter("user", userName); - urlBuilder.addQueryParameter("duration", duration); - urlBuilder.addQueryParameter("category", category); - urlBuilder.addQueryParameter("limit", limit); - urlBuilder.addQueryParameter("offset", offset); - Timber.i("Url %s", urlBuilder.toString()); - Request request = new Request.Builder() - .url(urlBuilder.toString()) - .build(); - return Observable.fromCallable(() -> { - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return new LeaderboardResponse(); - } - Timber.d("Response for leaderboard is %s", json); - try { - return gson.fromJson(json, LeaderboardResponse.class); - } catch (Exception e) { - return new LeaderboardResponse(); - } - } - return new LeaderboardResponse(); - }); - } - - /** - * This method will update the leaderboard user avatar - * - * @param username username to update - * @param avatar url of the new avatar - * @return UpdateAvatarResponse object - */ - @NonNull - public Single setAvatar(String username, String avatar) { - final String urlTemplate = wikiMediaToolforgeUrl - + UPDATE_AVATAR_END_POINT; - return Single.fromCallable(() -> { - String url = String.format(Locale.ENGLISH, - urlTemplate, - username, - avatar); - HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); - urlBuilder.addQueryParameter("user", username); - urlBuilder.addQueryParameter("avatar", avatar); - Timber.i("Url %s", urlBuilder.toString()); - Request request = new Request.Builder() - .url(urlBuilder.toString()) - .build(); - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return null; - } - try { - return gson.fromJson(json, UpdateAvatarResponse.class); - } catch (Exception e) { - return new UpdateAvatarResponse(); - } - } - return null; - }); - } - - @NonNull - public Single getUploadCount(String userName) { - HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder(); - urlBuilder - .addPathSegments("uploadsbyuser.py") - .addQueryParameter("user", userName); - - if (ConfigUtils.isBetaFlavour()) { - urlBuilder.addQueryParameter("labs", "commonswiki"); - } - - Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - return Single.fromCallable(() -> { - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.isSuccessful()) { - ResponseBody responseBody = response.body(); - if (null != responseBody) { - String responseBodyString = responseBody.string().trim(); - if (!TextUtils.isEmpty(responseBodyString)) { - try { - return Integer.parseInt(responseBodyString); - } catch (NumberFormatException e) { - Timber.e(e); - } - } - } - } - return 0; - }); - } - - @NonNull - public Single getWikidataEdits(String userName) { - HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder(); - urlBuilder - .addPathSegments("wikidataedits.py") - .addQueryParameter("user", userName); - - if (ConfigUtils.isBetaFlavour()) { - urlBuilder.addQueryParameter("labs", "commonswiki"); - } - - Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - return Single.fromCallable(() -> { - Response response = okHttpClient.newCall(request).execute(); - if (response != null && - response.isSuccessful() && response.body() != null) { - String json = response.body().string(); - if (json == null) { - return 0; - } - // Extract JSON from response - json = json.substring(json.indexOf('{')); - GetWikidataEditCountResponse countResponse = gson - .fromJson(json, GetWikidataEditCountResponse.class); - if (null != countResponse) { - return countResponse.getWikidataEditCount(); - } - } - return 0; - }); - } - - /** - * This takes userName as input, which is then used to fetch the feedback/achievements - * statistics using OkHttp and JavaRx. This function return JSONObject - * - * @param userName MediaWiki user name - * @return - */ - public Single getAchievements(String userName) { - final String fetchAchievementUrlTemplate = - wikiMediaToolforgeUrl + (ConfigUtils.isBetaFlavour() ? "/feedback.py?labs=commonswiki" - : "/feedback.py"); - return Single.fromCallable(() -> { - String url = String.format( - Locale.ENGLISH, - fetchAchievementUrlTemplate, - userName); - HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); - urlBuilder.addQueryParameter("user", userName); - Request request = new Request.Builder() - .url(urlBuilder.toString()) - .build(); - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return null; - } - // Extract JSON from response - json = json.substring(json.indexOf('{')); - Timber.d("Response for achievements is %s", json); - try { - return gson.fromJson(json, FeedbackResponse.class); - } catch (Exception e) { - return new FeedbackResponse(0, 0, 0, new FeaturedImages(0, 0), 0, ""); - } - - - } - return null; - }); - } - - /** - * Make API Call to get Nearby Places - * - * @param cur Search lat long - * @param language Language - * @param radius Search Radius - * @return - * @throws Exception - */ - @Nullable - public List getNearbyPlaces(final LatLng cur, final String language, final double radius, - final String customQuery) - throws Exception { - - Timber.d("Fetching nearby items at radius %s", radius); - Timber.d("CUSTOM_SPARQL%s", String.valueOf(customQuery != null)); - final String wikidataQuery; - if (customQuery != null) { - wikidataQuery = customQuery; - } else { - wikidataQuery = FileUtils.readFromResource( - "/queries/radius_query_for_upload_wizard.rq"); - } - final String query = wikidataQuery - .replace("${RAD}", String.format(Locale.ROOT, "%.2f", radius)) - .replace("${LAT}", String.format(Locale.ROOT, "%.4f", cur.getLatitude())) - .replace("${LONG}", String.format(Locale.ROOT, "%.4f", cur.getLongitude())) - .replace("${LANG}", language); - - final HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - - final Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - final Response response = okHttpClient.newCall(request).execute(); - if (response.body() != null && response.isSuccessful()) { - final String json = response.body().string(); - final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class); - final List bindings = nearbyResponse.getResults().getBindings(); - final List places = new ArrayList<>(); - for (final NearbyResultItem item : bindings) { - final Place placeFromNearbyItem = Place.from(item); - placeFromNearbyItem.setMonument(false); - places.add(placeFromNearbyItem); - } - return places; - } - throw new Exception(response.message()); - } - - /** - * Retrieves nearby places based on screen coordinates and optional query parameters. - * - * @param screenTopRight The top right corner of the screen (latitude, longitude). - * @param screenBottomLeft The bottom left corner of the screen (latitude, longitude). - * @param language The language for the query. - * @param shouldQueryForMonuments Flag indicating whether to include monuments in the query. - * @param customQuery Optional custom SPARQL query to use instead of default - * queries. - * @return A list of nearby places. - * @throws Exception If an error occurs during the retrieval process. - */ - @Nullable - public List getNearbyPlaces( - final fr.free.nrw.commons.location.LatLng screenTopRight, - final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String language, - final boolean shouldQueryForMonuments, final String customQuery) - throws Exception { - - Timber.d("CUSTOM_SPARQL%s", String.valueOf(customQuery != null)); - - final String wikidataQuery; - if (customQuery != null) { - wikidataQuery = customQuery; - } else if (!shouldQueryForMonuments) { - wikidataQuery = FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq"); - } else { - wikidataQuery = FileUtils.readFromResource( - "/queries/rectangle_query_for_nearby_monuments.rq"); - } - - final double westCornerLat = screenTopRight.getLatitude(); - final double westCornerLong = screenTopRight.getLongitude(); - final double eastCornerLat = screenBottomLeft.getLatitude(); - final double eastCornerLong = screenBottomLeft.getLongitude(); - - final String query = wikidataQuery - .replace("${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat)) - .replace("${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong)) - .replace("${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat)) - .replace("${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong)) - .replace("${LANG}", language); - final HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - - final Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - final Response response = okHttpClient.newCall(request).execute(); - if (response.body() != null && response.isSuccessful()) { - final String json = response.body().string(); - final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class); - final List bindings = nearbyResponse.getResults().getBindings(); - final List places = new ArrayList<>(); - for (final NearbyResultItem item : bindings) { - final Place placeFromNearbyItem = Place.from(item); - if (shouldQueryForMonuments && item.getMonument() != null) { - placeFromNearbyItem.setMonument(true); - } else { - placeFromNearbyItem.setMonument(false); - } - places.add(placeFromNearbyItem); - } - return places; - } - throw new Exception(response.message()); - } - - /** - * Retrieves a list of places based on the provided list of places and language. - * - * @param placeList A list of Place objects for which to fetch information. - * @param language The language code to use for the query. - * @return A list of Place objects with additional information retrieved from Wikidata, or null - * if an error occurs. - * @throws IOException If there is an issue with reading the resource file or executing the HTTP - * request. - */ - @Nullable - public List getPlaces( - final List placeList, final String language) throws IOException { - final String wikidataQuery = FileUtils.readFromResource("/queries/query_for_item.rq"); - String qids = ""; - for (final Place place : placeList) { - qids += "\n" + ("wd:" + place.getWikiDataEntityId()); - } - final String query = wikidataQuery - .replace("${ENTITY}", qids) - .replace("${LANG}", language); - final HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - - final Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - try (Response response = okHttpClient.newCall(request).execute()) { - if (response.isSuccessful()) { - final String json = response.body().string(); - final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class); - final List bindings = nearbyResponse.getResults().getBindings(); - final List places = new ArrayList<>(); - for (final NearbyResultItem item : bindings) { - final Place placeFromNearbyItem = Place.from(item); - places.add(placeFromNearbyItem); - } - return places; - } else { - throw new IOException("Unexpected response code: " + response.code()); - } - } - } - - /** - * Make API Call to get Places - * - * @param leftLatLng Left lat long - * @param rightLatLng Right lat long - * @return - * @throws Exception - */ - @Nullable - public String getPlacesAsKML(final LatLng leftLatLng, final LatLng rightLatLng) - throws Exception { - String kmlString = "\n" + - "\n" + - "\n" + - " "; - List placeBindings = runQuery(leftLatLng, - rightLatLng); - if (placeBindings != null) { - for (PlaceBindings item : placeBindings) { - if (item.getItem() != null && item.getLabel() != null && item.getClas() != null) { - String input = item.getLocation().getValue(); - Pattern pattern = Pattern.compile( - "Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)"); - Matcher matcher = pattern.matcher(input); - - if (matcher.find()) { - String longStr = matcher.group(1); - String latStr = matcher.group(2); - String itemUrl = item.getItem().getValue(); - String itemName = item.getLabel().getValue().replace("&", "&"); - String itemLatitude = latStr; - String itemLongitude = longStr; - String itemClass = item.getClas().getValue(); - - String formattedItemName = - !itemClass.isEmpty() ? itemName + " (" + itemClass + ")" - : itemName; - - String kmlEntry = "\n \n" + - " " + formattedItemName + "\n" + - " " + itemUrl + "\n" + - " \n" + - " " + itemLongitude + "," - + itemLatitude - + "\n" + - " \n" + - " "; - kmlString = kmlString + kmlEntry; - } else { - Timber.e("No match found"); - } - } - } - } - kmlString = kmlString + "\n \n" + - "\n"; - return kmlString; - } - - /** - * Make API Call to get Places - * - * @param leftLatLng Left lat long - * @param rightLatLng Right lat long - * @return - * @throws Exception - */ - @Nullable - public String getPlacesAsGPX(final LatLng leftLatLng, final LatLng rightLatLng) - throws Exception { - String gpxString = "\n" + - "" - + "\n"; - - List placeBindings = runQuery(leftLatLng, rightLatLng); - if (placeBindings != null) { - for (PlaceBindings item : placeBindings) { - if (item.getItem() != null && item.getLabel() != null && item.getClas() != null) { - String input = item.getLocation().getValue(); - Pattern pattern = Pattern.compile( - "Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)"); - Matcher matcher = pattern.matcher(input); - - if (matcher.find()) { - String longStr = matcher.group(1); - String latStr = matcher.group(2); - String itemUrl = item.getItem().getValue(); - String itemName = item.getLabel().getValue().replace("&", "&"); - String itemLatitude = latStr; - String itemLongitude = longStr; - String itemClass = item.getClas().getValue(); - - String formattedItemName = - !itemClass.isEmpty() ? itemName + " (" + itemClass + ")" - : itemName; - - String gpxEntry = - "\n \n" + - " " + itemName + "\n" + - " " + itemUrl + "\n" + - " "; - gpxString = gpxString + gpxEntry; - - } else { - Timber.e("No match found"); - } - } - } - - } - gpxString = gpxString + "\n"; - return gpxString; - } - - private List runQuery(final LatLng currentLatLng, final LatLng nextLatLng) - throws IOException { - - final String wikidataQuery = FileUtils.readFromResource("/queries/places_query.rq"); - final String query = wikidataQuery - .replace("${LONGITUDE}", - String.format(Locale.ROOT, "%.2f", currentLatLng.getLongitude())) - .replace("${LATITUDE}", String.format(Locale.ROOT, "%.4f", currentLatLng.getLatitude())) - .replace("${NEXT_LONGITUDE}", - String.format(Locale.ROOT, "%.4f", nextLatLng.getLongitude())) - .replace("${NEXT_LATITUDE}", - String.format(Locale.ROOT, "%.4f", nextLatLng.getLatitude())); - - final HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - - final Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - final Response response = okHttpClient.newCall(request).execute(); - if (response.body() != null && response.isSuccessful()) { - final String json = response.body().string(); - final ItemsClass item = gson.fromJson(json, ItemsClass.class); - return item.getResults().getBindings(); - } else { - return null; - } - } - - /** - * Make API Call to get Nearby Places Implementation does not expects a custom query - * - * @param cur Search lat long - * @param language Language - * @param radius Search Radius - * @return - * @throws Exception - */ - @Nullable - public List getNearbyPlaces(final LatLng cur, final String language, final double radius) - throws Exception { - return getNearbyPlaces(cur, language, radius, null); - } - - /** - * Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example: - * bridge -> suspended bridge, aqueduct, etc - */ - public Single> getChildDepictions(String qid, int startPosition, - int limit) throws IOException { - return depictedItemsFrom( - sparqlQuery(qid, startPosition, limit, "/queries/subclasses_query.rq")); - } - - /** - * Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example: - * bridge -> suspended bridge, aqueduct, etc - */ - public Single> getParentDepictions(String qid, int startPosition, - int limit) throws IOException { - return depictedItemsFrom(sparqlQuery(qid, startPosition, limit, - "/queries/parentclasses_query.rq")); - } - - private Single> depictedItemsFrom(Request request) { - return depictsClient.toDepictions(Single.fromCallable(() -> { - try (ResponseBody body = okHttpClient.newCall(request).execute().body()) { - return gson.fromJson(body.string(), SparqlResponse.class); - } - }).doOnError(Timber::e)); - } - - @NotNull - private Request sparqlQuery(String qid, int startPosition, int limit, String fileName) - throws IOException { - String query = FileUtils.readFromResource(fileName) - .replace("${QID}", qid) - .replace("${LANG}", "\"" + Locale.getDefault().getLanguage() + "\"") - .replace("${LIMIT}", "" + limit) - .replace("${OFFSET}", "" + startPosition); - HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - return new Request.Builder() - .url(urlBuilder.build()) - .build(); - } - - public Single getCampaigns() { - return Single.fromCallable(() -> { - Request request = new Request.Builder().url(campaignsUrl) - .build(); - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return null; - } - return gson.fromJson(json, CampaignResponseDTO.class); - } - return null; - }); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt new file mode 100644 index 000000000..71ea1d692 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt @@ -0,0 +1,624 @@ +package fr.free.nrw.commons.mwapi + +import android.text.TextUtils +import com.google.gson.Gson +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.campaigns.CampaignResponseDTO +import fr.free.nrw.commons.explore.depictions.DepictsClient +import fr.free.nrw.commons.fileusages.FileUsagesResponse +import fr.free.nrw.commons.fileusages.GlobalFileUsagesResponse +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.model.ItemsClass +import fr.free.nrw.commons.nearby.model.NearbyResponse +import fr.free.nrw.commons.nearby.model.PlaceBindings +import fr.free.nrw.commons.profile.achievements.FeaturedImages +import fr.free.nrw.commons.profile.achievements.FeedbackResponse +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants +import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse +import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse +import fr.free.nrw.commons.upload.FileUtils +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.wikidata.model.GetWikidataEditCountResponse +import io.reactivex.Observable +import io.reactivex.Single +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import timber.log.Timber +import java.io.IOException +import java.util.Locale +import java.util.regex.Pattern +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Test methods in ok http api client + */ +@Singleton +class OkHttpJsonApiClient @Inject constructor( + private val okHttpClient: OkHttpClient, + private val depictsClient: DepictsClient, + private val wikiMediaToolforgeUrl: HttpUrl, + private val sparqlQueryUrl: String, + private val campaignsUrl: String, + private val gson: Gson +) { + fun getLeaderboard( + userName: String?, duration: String?, + category: String?, limit: String?, offset: String? + ): Observable { + val fetchLeaderboardUrlTemplate = + wikiMediaToolforgeUrl.toString() + LeaderboardConstants.LEADERBOARD_END_POINT + val url = String.format( + Locale.ENGLISH, + fetchLeaderboardUrlTemplate, userName, duration, category, limit, offset + ) + val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder() + .addQueryParameter("user", userName) + .addQueryParameter("duration", duration) + .addQueryParameter("category", category) + .addQueryParameter("limit", limit) + .addQueryParameter("offset", offset) + Timber.i("Url %s", urlBuilder.toString()) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + return Observable.fromCallable({ + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() + Timber.d("Response for leaderboard is %s", json) + try { + return@fromCallable gson.fromJson( + json, + LeaderboardResponse::class.java + ) + } catch (e: Exception) { + return@fromCallable LeaderboardResponse() + } + } + LeaderboardResponse() + }) + } + + /** + * Show where file is being used on Commons. + */ + suspend fun getFileUsagesOnCommons( + fileName: String?, + pageSize: Int + ): FileUsagesResponse? { + return withContext(Dispatchers.IO) { + + return@withContext try { + + val urlBuilder = BuildConfig.FILE_USAGES_BASE_URL.toHttpUrlOrNull()!!.newBuilder() + urlBuilder.addQueryParameter("prop", "fileusage") + urlBuilder.addQueryParameter("titles", fileName) + urlBuilder.addQueryParameter("fulimit", pageSize.toString()) + + Timber.i("Url %s", urlBuilder.toString()) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() + gson.fromJson( + json, + FileUsagesResponse::class.java + ) + } else null + } catch (e: Exception) { + Timber.e(e) + null + } + } + } + + /** + * Show where file is being used on non-Commons wikis, typically the Wikipedias in various languages. + */ + suspend fun getGlobalFileUsages( + fileName: String?, + pageSize: Int + ): GlobalFileUsagesResponse? { + + return withContext(Dispatchers.IO) { + + return@withContext try { + + val urlBuilder = BuildConfig.FILE_USAGES_BASE_URL.toHttpUrlOrNull()!!.newBuilder() + urlBuilder.addQueryParameter("prop", "globalusage") + urlBuilder.addQueryParameter("titles", fileName) + urlBuilder.addQueryParameter("gulimit", pageSize.toString()) + + Timber.i("Url %s", urlBuilder.toString()) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() + + gson.fromJson( + json, + GlobalFileUsagesResponse::class.java + ) + } else null + } catch (e: Exception) { + Timber.e(e) + null + } + } + } + + fun setAvatar(username: String?, avatar: String?): Single { + val urlTemplate = wikiMediaToolforgeUrl + .toString() + LeaderboardConstants.UPDATE_AVATAR_END_POINT + return Single.fromCallable({ + val url = String.format(Locale.ENGLISH, urlTemplate, username, avatar) + val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder() + .addQueryParameter("user", username) + .addQueryParameter("avatar", avatar) + Timber.i("Url %s", urlBuilder.toString()) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() ?: return@fromCallable null + try { + return@fromCallable gson.fromJson( + json, + UpdateAvatarResponse::class.java + ) + } catch (e: Exception) { + return@fromCallable UpdateAvatarResponse() + } + } + null + }) + } + + fun getUploadCount(userName: String?): Single { + val urlBuilder: HttpUrl.Builder = wikiMediaToolforgeUrl.newBuilder() + .addPathSegments("uploadsbyuser.py") + .addQueryParameter("user", userName) + + if (isBetaFlavour) { + urlBuilder.addQueryParameter("labs", "commonswiki") + } + + val request: Request = Request.Builder() + .url(urlBuilder.build()) + .build() + + return Single.fromCallable({ + val response: Response = okHttpClient.newCall(request).execute() + if (response != null && response.isSuccessful) { + val responseBody = response.body + if (null != responseBody) { + val responseBodyString = responseBody.string().trim { it <= ' ' } + if (!TextUtils.isEmpty(responseBodyString)) { + try { + return@fromCallable responseBodyString.toInt() + } catch (e: NumberFormatException) { + Timber.e(e) + } + } + } + } + 0 + }) + } + + fun getWikidataEdits(userName: String?): Single { + val urlBuilder: HttpUrl.Builder = wikiMediaToolforgeUrl.newBuilder() + .addPathSegments("wikidataedits.py") + .addQueryParameter("user", userName) + + if (isBetaFlavour) { + urlBuilder.addQueryParameter("labs", "commonswiki") + } + + val request: Request = Request.Builder() + .url(urlBuilder.build()) + .build() + + return Single.fromCallable({ + val response: Response = okHttpClient.newCall(request).execute() + if (response != null && response.isSuccessful && response.body != null) { + var json: String = response.body!!.string() + // Extract JSON from response + json = json.substring(json.indexOf('{')) + val countResponse = gson + .fromJson( + json, + GetWikidataEditCountResponse::class.java + ) + if (null != countResponse) { + return@fromCallable countResponse.wikidataEditCount + } + } + 0 + }) + } + + fun getAchievements(userName: String?): Single { + val suffix = if (isBetaFlavour) "/feedback.py?labs=commonswiki" else "/feedback.py" + val fetchAchievementUrlTemplate = wikiMediaToolforgeUrl.toString() + suffix + return Single.fromCallable({ + val url = String.format( + Locale.ENGLISH, + fetchAchievementUrlTemplate, + userName + ) + val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder() + .addQueryParameter("user", userName) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + var json: String = response.body!!.string() + // Extract JSON from response + json = json.substring(json.indexOf('{')) + Timber.d("Response for achievements is %s", json) + try { + return@fromCallable gson.fromJson( + json, + FeedbackResponse::class.java + ) + } catch (e: Exception) { + return@fromCallable FeedbackResponse(0, 0, 0, FeaturedImages(0, 0), 0, "") + } + } + null + }) + } + + @JvmOverloads + @Throws(Exception::class) + fun getNearbyPlaces( + cur: LatLng, language: String, radius: Double, + customQuery: String? = null + ): List? { + Timber.d("Fetching nearby items at radius %s", radius) + Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString()) + val wikidataQuery: String = if (customQuery != null) { + customQuery + } else { + FileUtils.readFromResource("/queries/radius_query_for_upload_wizard.rq") + } + val query = wikidataQuery + .replace("\${RAD}", String.format(Locale.ROOT, "%.2f", radius)) + .replace("\${LAT}", String.format(Locale.ROOT, "%.4f", cur.latitude)) + .replace("\${LONG}", String.format(Locale.ROOT, "%.4f", cur.longitude)) + .replace("\${LANG}", language) + + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + + val request: Request = Request.Builder() + .url(urlBuilder.build()) + .build() + + val response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json = response.body!!.string() + val nearbyResponse = gson.fromJson(json, NearbyResponse::class.java) + val bindings = nearbyResponse.results.bindings + val places: MutableList = ArrayList() + for (item in bindings) { + val placeFromNearbyItem = Place.from(item) + placeFromNearbyItem.isMonument = false + places.add(placeFromNearbyItem) + } + return places + } + throw Exception(response.message) + } + + @Throws(Exception::class) + fun getNearbyPlaces( + screenTopRight: LatLng, + screenBottomLeft: LatLng, language: String, + shouldQueryForMonuments: Boolean, customQuery: String? + ): List? { + Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString()) + + val wikidataQuery: String = if (customQuery != null) { + customQuery + } else if (!shouldQueryForMonuments) { + FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq") + } else { + FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq") + } + + val westCornerLat = screenTopRight.latitude + val westCornerLong = screenTopRight.longitude + val eastCornerLat = screenBottomLeft.latitude + val eastCornerLong = screenBottomLeft.longitude + + val query = wikidataQuery + .replace("\${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat)) + .replace("\${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong)) + .replace("\${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat)) + .replace("\${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong)) + .replace("\${LANG}", language) + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + + val request: Request = Request.Builder() + .url(urlBuilder.build()) + .build() + + val response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json = response.body!!.string() + val nearbyResponse = gson.fromJson(json, NearbyResponse::class.java) + val bindings = nearbyResponse.results.bindings + val places: MutableList = ArrayList() + for (item in bindings) { + val placeFromNearbyItem = Place.from(item) + if (shouldQueryForMonuments && item.getMonument() != null) { + placeFromNearbyItem.isMonument = true + } else { + placeFromNearbyItem.isMonument = false + } + places.add(placeFromNearbyItem) + } + return places + } + throw Exception(response.message) + } + + @Throws(IOException::class) + fun getPlaces( + placeList: List, language: String + ): List? { + val wikidataQuery = FileUtils.readFromResource("/queries/query_for_item.rq") + var qids = "" + for (place in placeList) { + qids += """ +${"wd:" + place.wikiDataEntityId}""" + } + val query = wikidataQuery + .replace("\${ENTITY}", qids) + .replace("\${LANG}", language) + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + + val request: Request = Request.Builder().url(urlBuilder.build()).build() + + okHttpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val json = response.body!!.string() + val nearbyResponse = gson.fromJson(json, NearbyResponse::class.java) + val bindings = nearbyResponse.results.bindings + val places: MutableList = ArrayList() + for (item in bindings) { + val placeFromNearbyItem = Place.from(item) + places.add(placeFromNearbyItem) + } + return places + } else { + throw IOException("Unexpected response code: " + response.code) + } + } + } + + @Throws(Exception::class) + fun getPlacesAsKML(leftLatLng: LatLng, rightLatLng: LatLng): String? { + var kmlString = """ + + + """ + val placeBindings = runQuery( + leftLatLng, + rightLatLng + ) + if (placeBindings != null) { + for ((item1, label, location, clas) in placeBindings) { + if (item1 != null && label != null && clas != null) { + val input = location.value + val pattern = Pattern.compile( + "Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)" + ) + val matcher = pattern.matcher(input) + + if (matcher.find()) { + val longStr = matcher.group(1) + val latStr = matcher.group(2) + val itemUrl = item1.value + val itemName = label.value.replace("&", "&") + val itemLatitude = latStr + val itemLongitude = longStr + val itemClass = clas.value + + val formattedItemName = + if (!itemClass.isEmpty()) + "$itemName ($itemClass)" + else + itemName + + val kmlEntry = (""" + + $formattedItemName + $itemUrl + + $itemLongitude,$itemLatitude + + """) + kmlString = kmlString + kmlEntry + } else { + Timber.e("No match found") + } + } + } + } + kmlString = """$kmlString + + +""" + return kmlString + } + + @Throws(Exception::class) + fun getPlacesAsGPX(leftLatLng: LatLng, rightLatLng: LatLng): String? { + var gpxString = (""" + +""") + + val placeBindings = runQuery(leftLatLng, rightLatLng) + if (placeBindings != null) { + for ((item1, label, location, clas) in placeBindings) { + if (item1 != null && label != null && clas != null) { + val input = location.value + val pattern = Pattern.compile( + "Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)" + ) + val matcher = pattern.matcher(input) + + if (matcher.find()) { + val longStr = matcher.group(1) + val latStr = matcher.group(2) + val itemUrl = item1.value + val itemName = label.value.replace("&", "&") + val itemLatitude = latStr + val itemLongitude = longStr + val itemClass = clas.value + + val formattedItemName = if (!itemClass.isEmpty()) + "$itemName ($itemClass)" + else + itemName + + val gpxEntry = + (""" + + $itemName + $itemUrl + """) + gpxString = gpxString + gpxEntry + } else { + Timber.e("No match found") + } + } + } + } + gpxString = "$gpxString\n" + return gpxString + } + + @Throws(IOException::class) + fun getChildDepictions( + qid: String, startPosition: Int, + limit: Int + ): Single> = + depictedItemsFrom(sparqlQuery(qid, startPosition, limit, "/queries/subclasses_query.rq")) + + @Throws(IOException::class) + fun getParentDepictions( + qid: String, startPosition: Int, + limit: Int + ): Single> = depictedItemsFrom( + sparqlQuery( + qid, + startPosition, + limit, + "/queries/parentclasses_query.rq" + ) + ) + + fun getCampaigns(): Single { + return Single.fromCallable({ + val request: Request = Request.Builder().url(campaignsUrl).build() + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() + return@fromCallable gson.fromJson( + json, + CampaignResponseDTO::class.java + ) + } + null + }) + } + + private fun depictedItemsFrom(request: Request): Single> { + return depictsClient.toDepictions(Single.fromCallable({ + okHttpClient.newCall(request).execute().body.use { body -> + return@fromCallable gson.fromJson( + body!!.string(), + SparqlResponse::class.java + ) + } + }).doOnError({ t: Throwable? -> Timber.e(t) })) + } + + @Throws(IOException::class) + private fun sparqlQuery( + qid: String, + startPosition: Int, + limit: Int, + fileName: String + ): Request { + val query = FileUtils.readFromResource(fileName) + .replace("\${QID}", qid) + .replace("\${LANG}", "\"" + Locale.getDefault().language + "\"") + .replace("\${LIMIT}", "" + limit) + .replace("\${OFFSET}", "" + startPosition) + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + return Request.Builder().url(urlBuilder.build()).build() + } + + @Throws(IOException::class) + private fun runQuery(currentLatLng: LatLng, nextLatLng: LatLng): List? { + val wikidataQuery = FileUtils.readFromResource("/queries/places_query.rq") + val query = wikidataQuery + .replace("\${LONGITUDE}", String.format(Locale.ROOT, "%.2f", currentLatLng.longitude)) + .replace("\${LATITUDE}", String.format(Locale.ROOT, "%.4f", currentLatLng.latitude)) + .replace("\${NEXT_LONGITUDE}", String.format(Locale.ROOT, "%.4f", nextLatLng.longitude)) + .replace("\${NEXT_LATITUDE}", String.format(Locale.ROOT, "%.4f", nextLatLng.latitude)) + + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + + val request: Request = Request.Builder().url(urlBuilder.build()).build() + + val response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json = response.body!!.string() + val item = gson.fromJson(json, ItemsClass::class.java) + return item.results.bindings + } else { + return null + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.java b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.java deleted file mode 100644 index 0bd8333e3..000000000 --- a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.java +++ /dev/null @@ -1,238 +0,0 @@ -package fr.free.nrw.commons.navtab; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; -import fr.free.nrw.commons.AboutActivity; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.CommonsApplication.ActivityLogoutListener; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.WelcomeActivity; -import fr.free.nrw.commons.actions.PageEditClient; -import fr.free.nrw.commons.databinding.FragmentMoreBottomSheetBinding; -import fr.free.nrw.commons.di.ApplicationlessInjection; -import fr.free.nrw.commons.feedback.FeedbackContentCreator; -import fr.free.nrw.commons.feedback.model.Feedback; -import fr.free.nrw.commons.feedback.FeedbackDialog; -import fr.free.nrw.commons.kvstore.BasicKvStore; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.logging.CommonsLogSender; -import fr.free.nrw.commons.profile.ProfileActivity; -import fr.free.nrw.commons.review.ReviewActivity; -import fr.free.nrw.commons.settings.SettingsActivity; -import io.reactivex.Single; -import io.reactivex.SingleSource; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; -import java.util.concurrent.Callable; -import javax.inject.Inject; -import javax.inject.Named; - -public class MoreBottomSheetFragment extends BottomSheetDialogFragment { - - @Inject - CommonsLogSender commonsLogSender; - - private TextView moreProfile; - - @Inject @Named("default_preferences") - JsonKvStore store; - - @Inject - @Named("commons-page-edit") - PageEditClient pageEditClient; - - private static final String GITHUB_ISSUES_URL = "https://github.com/commons-app/apps-android-commons/issues"; - @Nullable - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { - super.onCreateView(inflater, container, savedInstanceState); - final @NonNull FragmentMoreBottomSheetBinding binding = - FragmentMoreBottomSheetBinding.inflate(inflater, container, false); - moreProfile = binding.moreProfile; - - if(store.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED)){ - binding.morePeerReview.setVisibility(View.GONE); - } - - binding.moreLogout.setOnClickListener(v -> onLogoutClicked()); - binding.moreFeedback.setOnClickListener(v -> onFeedbackClicked()); - binding.moreAbout.setOnClickListener(v -> onAboutClicked()); - binding.moreTutorial.setOnClickListener(v -> onTutorialClicked()); - binding.moreSettings.setOnClickListener(v -> onSettingsClicked()); - binding.moreProfile.setOnClickListener(v -> onProfileClicked()); - binding.morePeerReview.setOnClickListener(v -> onPeerReviewClicked()); - binding.moreFeedbackGithub.setOnClickListener(v -> onFeedbackGithubClicked()); - - setUserName(); - return binding.getRoot(); - } - - private void onFeedbackGithubClicked() { - final Intent intent; - intent = new Intent(Intent.ACTION_VIEW); - intent.setData(Uri.parse(GITHUB_ISSUES_URL)); - startActivity(intent); - } - - @Override - public void onAttach(@NonNull final Context context) { - super.onAttach(context); - ApplicationlessInjection - .getInstance(requireActivity().getApplicationContext()) - .getCommonsApplicationComponent() - .inject(this); - } - - /** - * Set the username and user achievements level (if available) in navigationHeader. - */ - private void setUserName() { - BasicKvStore store = new BasicKvStore(this.getContext(), getUserName()); - String level = store.getString("userAchievementsLevel","0"); - if (level.equals("0")) { - moreProfile.setText(getUserName() + " (" + getString(R.string.see_your_achievements) + ")"); - } - else { - moreProfile.setText(getUserName() + " (" + getString(R.string.level) + " " + level + ")"); - } - } - - private String getUserName(){ - final AccountManager accountManager = AccountManager.get(getActivity()); - final Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE); - if (allAccounts.length != 0) { - return allAccounts[0].name; - } - return ""; - } - - - protected void onLogoutClicked() { - new AlertDialog.Builder(requireActivity()) - .setMessage(R.string.logout_verification) - .setCancelable(false) - .setPositiveButton(R.string.yes, (dialog, which) -> { - final CommonsApplication app = (CommonsApplication) - requireContext().getApplicationContext(); - app.clearApplicationData(requireContext(), new ActivityLogoutListener(requireActivity(), getContext())); - }) - .setNegativeButton(R.string.no, (dialog, which) -> dialog.cancel()) - .show(); - } - - protected void onFeedbackClicked() { - showFeedbackDialog(); - } - - /** - * Creates and shows a dialog asking feedback from users - */ - private void showFeedbackDialog() { - new FeedbackDialog(getContext(), this::uploadFeedback).show(); - } - - /** - * uploads feedback data on the server - */ - void uploadFeedback(final Feedback feedback) { - final FeedbackContentCreator feedbackContentCreator = new FeedbackContentCreator(getContext(), feedback); - - final Single single = - pageEditClient.createNewSection( - "Commons:Mobile_app/Feedback", - feedbackContentCreator.getSectionTitle(), - feedbackContentCreator.getSectionText(), - "New feedback on version " + feedback.getVersion() + " of the app" - ) - .flatMapSingle(Single::just) - .firstOrError(); - - Single.defer((Callable>) () -> single) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(aBoolean -> { - if (aBoolean) { - Toast.makeText(getContext(), getString(R.string.thanks_feedback), Toast.LENGTH_SHORT) - .show(); - } else { - Toast.makeText(getContext(), getString(R.string.error_feedback), - Toast.LENGTH_SHORT).show(); - } - }); - } - - /** - * This method shows the alert dialog when a user wants to send feedback about the app. - */ - private void showAlertDialog() { - new AlertDialog.Builder(requireActivity()) - .setMessage(R.string.feedback_sharing_data_alert) - .setCancelable(false) - .setPositiveButton(R.string.ok, (dialog, which) -> sendFeedback()) - .show(); - } - - /** - * This method collects the feedback message and starts the activity with implicit intent - * to available email client. - */ - private void sendFeedback() { - final String technicalInfo = commonsLogSender.getExtraInfo(); - - final Intent feedbackIntent = new Intent(Intent.ACTION_SENDTO); - feedbackIntent.setType("message/rfc822"); - feedbackIntent.setData(Uri.parse("mailto:")); - feedbackIntent.putExtra(Intent.EXTRA_EMAIL, - new String[]{CommonsApplication.FEEDBACK_EMAIL}); - feedbackIntent.putExtra(Intent.EXTRA_SUBJECT, - CommonsApplication.FEEDBACK_EMAIL_SUBJECT); - feedbackIntent.putExtra(Intent.EXTRA_TEXT, String.format( - "\n\n%s\n%s", CommonsApplication.FEEDBACK_EMAIL_TEMPLATE_HEADER, technicalInfo)); - try { - startActivity(feedbackIntent); - } catch (final ActivityNotFoundException e) { - Toast.makeText(getActivity(), R.string.no_email_client, Toast.LENGTH_SHORT).show(); - } - } - - protected void onAboutClicked() { - final Intent intent = new Intent(getActivity(), AboutActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP); - requireActivity().startActivity(intent); - } - - protected void onTutorialClicked() { - WelcomeActivity.startYourself(getActivity()); - } - - protected void onSettingsClicked() { - final Intent intent = new Intent(getActivity(), SettingsActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP); - requireActivity().startActivity(intent); - } - - protected void onProfileClicked() { - ProfileActivity.startYourself(getActivity(), getUserName(), false); - } - - protected void onPeerReviewClicked() { - ReviewActivity.startYourself(getActivity(), getString(R.string.title_activity_review)); - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt new file mode 100644 index 000000000..a79df3e15 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt @@ -0,0 +1,258 @@ +package fr.free.nrw.commons.navtab + +import android.accounts.AccountManager +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import fr.free.nrw.commons.AboutActivity +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.CommonsApplication.ActivityLogoutListener +import fr.free.nrw.commons.R +import fr.free.nrw.commons.WelcomeActivity +import fr.free.nrw.commons.actions.PageEditClient +import fr.free.nrw.commons.databinding.FragmentMoreBottomSheetBinding +import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.feedback.FeedbackContentCreator +import fr.free.nrw.commons.feedback.FeedbackDialog +import fr.free.nrw.commons.feedback.OnFeedbackSubmitCallback +import fr.free.nrw.commons.feedback.model.Feedback +import fr.free.nrw.commons.kvstore.BasicKvStore +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.logging.CommonsLogSender +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.review.ReviewActivity +import fr.free.nrw.commons.settings.SettingsActivity +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Named + + +class MoreBottomSheetFragment : BottomSheetDialogFragment() { + + @Inject + lateinit var commonsLogSender: CommonsLogSender + + @Inject + @field: Named("default_preferences") + lateinit var store: JsonKvStore + + @Inject + @field: Named("commons-page-edit") + lateinit var pageEditClient: PageEditClient + + companion object { + private const val GITHUB_ISSUES_URL = + "https://github.com/commons-app/apps-android-commons/issues" + } + + private var binding: FragmentMoreBottomSheetBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentMoreBottomSheetBinding.inflate(inflater, container, false) + + if (store.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED)) { + binding?.morePeerReview?.visibility = View.GONE + } + + binding?.apply { + moreLogout.setOnClickListener { onLogoutClicked() } + moreFeedback.setOnClickListener { onFeedbackClicked() } + moreAbout.setOnClickListener { onAboutClicked() } + moreTutorial.setOnClickListener { onTutorialClicked() } + moreSettings.setOnClickListener { onSettingsClicked() } + moreProfile.setOnClickListener { onProfileClicked() } + morePeerReview.setOnClickListener { onPeerReviewClicked() } + moreFeedbackGithub.setOnClickListener { onFeedbackGithubClicked() } + } + + setUserName() + return binding?.root + } + + private fun onFeedbackGithubClicked() { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(GITHUB_ISSUES_URL) + } + startActivity(intent) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + ApplicationlessInjection + .getInstance(requireActivity().applicationContext) + .commonsApplicationComponent + .inject(this) + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + /** + * Set the username and user achievements level (if available) in navigationHeader. + */ + private fun setUserName() { + val store = BasicKvStore(requireContext(), getUserName()) + val level = store.getString("userAchievementsLevel", "0") + if (level == "0"){ + binding?.moreProfile?.text = getString( + R.string.profileLevel, + getUserName(), + getString(R.string.see_your_achievements) // Second argument + ) + } else { + binding?.moreProfile?.text = getString( + R.string.profileLevel, + getUserName(), + level + ) + } + } + + private fun getUserName(): String { + val accountManager = AccountManager.get(requireActivity()) + val allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE) + return if (allAccounts.isNotEmpty()) { + allAccounts[0].name + } else { + "" + } + } + + fun onLogoutClicked() { + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.logout_verification) + .setCancelable(false) + .setPositiveButton(R.string.yes) { _, _ -> + val app = requireContext().applicationContext as CommonsApplication + app.clearApplicationData(requireContext(), ActivityLogoutListener(requireActivity(), requireContext())) + } + .setNegativeButton(R.string.no) { dialog, _ -> dialog.cancel() } + .show() + } + + fun onFeedbackClicked() { + showFeedbackDialog() + } + + /** + * Creates and shows a dialog asking feedback from users + */ + private fun showFeedbackDialog() { + FeedbackDialog(requireContext(), object : OnFeedbackSubmitCallback{ + override fun onFeedbackSubmit(feedback: Feedback) { + uploadFeedback(feedback) + } + }).show() + } + + /** + * Uploads feedback data on the server + */ + @SuppressLint("CheckResult") + fun uploadFeedback(feedback: Feedback) { + val feedbackContentCreator = FeedbackContentCreator(requireContext(), feedback) + + val single = pageEditClient.createNewSection( + "Commons:Mobile_app/Feedback", + feedbackContentCreator.getSectionTitle(), + feedbackContentCreator.getSectionText(), + "New feedback on version ${feedback.version} of the app" + ) + .flatMapSingle { Single.just(it) } + .firstOrError() + + Single.defer { single } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ success -> + val messageResId = if (success) { + R.string.thanks_feedback + } else { + R.string.error_feedback + } + Toast.makeText(requireContext(), getString(messageResId), Toast.LENGTH_SHORT).show() + }, { error -> + Timber.e(error) + Toast.makeText(requireContext(), R.string.error_feedback, Toast.LENGTH_SHORT).show() + }) + } + + /** + * This method shows the alert dialog when a user wants to send feedback about the app. + */ + private fun showAlertDialog() { + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.feedback_sharing_data_alert) + .setCancelable(false) + .setPositiveButton(R.string.ok) { _, _ -> sendFeedback() } + .show() + } + + /** + * This method collects the feedback message and starts the activity with implicit intent + * to the available email client. + */ + @SuppressLint("IntentReset") + private fun sendFeedback() { + val technicalInfo = commonsLogSender.getExtraInfo() + + val feedbackIntent = Intent(Intent.ACTION_SENDTO).apply { + type = "message/rfc822" + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(CommonsApplication.FEEDBACK_EMAIL)) + putExtra(Intent.EXTRA_SUBJECT, CommonsApplication.FEEDBACK_EMAIL_SUBJECT) + putExtra(Intent.EXTRA_TEXT, "\n\n${CommonsApplication.FEEDBACK_EMAIL_TEMPLATE_HEADER}\n$technicalInfo") + } + + try { + startActivity(feedbackIntent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(activity, R.string.no_email_client, Toast.LENGTH_SHORT).show() + } + } + + fun onAboutClicked() { + val intent = Intent(activity, AboutActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + requireActivity().startActivity(intent) + } + + fun onTutorialClicked() { + WelcomeActivity.startYourself(requireActivity()) + } + + fun onSettingsClicked() { + val intent = Intent(activity, SettingsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + requireActivity().startActivity(intent) + } + + fun onProfileClicked() { + ProfileActivity.startYourself(requireActivity(), getUserName(), false) + } + + fun onPeerReviewClicked() { + ReviewActivity.startYourself(requireActivity(), getString(R.string.title_activity_review)) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.java b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.java deleted file mode 100644 index 3537d7f7b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.java +++ /dev/null @@ -1,142 +0,0 @@ -package fr.free.nrw.commons.navtab; - -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; -import fr.free.nrw.commons.AboutActivity; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.LoginActivity; -import fr.free.nrw.commons.databinding.FragmentMoreBottomSheetLoggedOutBinding; -import fr.free.nrw.commons.di.ApplicationlessInjection; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.logging.CommonsLogSender; -import fr.free.nrw.commons.settings.SettingsActivity; -import javax.inject.Inject; -import javax.inject.Named; -import timber.log.Timber; - -public class MoreBottomSheetLoggedOutFragment extends BottomSheetDialogFragment { - - private FragmentMoreBottomSheetLoggedOutBinding binding; - @Inject - CommonsLogSender commonsLogSender; - @Inject - @Named("default_preferences") - JsonKvStore applicationKvStore; - - @Nullable - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { - binding = FragmentMoreBottomSheetLoggedOutBinding.inflate(inflater, container, false); - return binding.getRoot(); - } - - @Override - public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { - binding.moreLogin.setOnClickListener(v -> onLogoutClicked()); - binding.moreFeedback.setOnClickListener(v -> onFeedbackClicked()); - binding.moreAbout.setOnClickListener(v -> onAboutClicked()); - binding.moreSettings.setOnClickListener(v -> onSettingsClicked()); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onAttach(@NonNull final Context context) { - super.onAttach(context); - ApplicationlessInjection - .getInstance(requireActivity().getApplicationContext()) - .getCommonsApplicationComponent() - .inject(this); - } - - public void onLogoutClicked() { - applicationKvStore.putBoolean("login_skipped", false); - final Intent intent = new Intent(getContext(), LoginActivity.class); - requireActivity().finish(); //Kill the activity from which you will go to next activity - startActivity(intent); - } - - public void onFeedbackClicked() { - showAlertDialog(); - } - - /** - * This method shows the alert dialog when a user wants to send feedback about the app. - */ - private void showAlertDialog() { - new AlertDialog.Builder(requireActivity()) - .setMessage(R.string.feedback_sharing_data_alert) - .setCancelable(false) - .setPositiveButton(R.string.ok, (dialog, which) -> { - sendFeedback(); - }) - .show(); - } - - /** - * This method collects the feedback message and starts and activity with implicit intent to - * available email client. - */ - private void sendFeedback() { - final String technicalInfo = commonsLogSender.getExtraInfo(); - - final Intent feedbackIntent = new Intent(Intent.ACTION_SENDTO); - feedbackIntent.setType("message/rfc822"); - feedbackIntent.setData(Uri.parse("mailto:")); - feedbackIntent.putExtra(Intent.EXTRA_EMAIL, - new String[]{CommonsApplication.FEEDBACK_EMAIL}); - feedbackIntent.putExtra(Intent.EXTRA_SUBJECT, - CommonsApplication.FEEDBACK_EMAIL_SUBJECT); - feedbackIntent.putExtra(Intent.EXTRA_TEXT, String.format( - "\n\n%s\n%s", CommonsApplication.FEEDBACK_EMAIL_TEMPLATE_HEADER, technicalInfo)); - try { - startActivity(feedbackIntent); - } catch (final ActivityNotFoundException e) { - Toast.makeText(getActivity(), R.string.no_email_client, Toast.LENGTH_SHORT).show(); - } - } - - public void onAboutClicked() { - final Intent intent = new Intent(getActivity(), AboutActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP); - requireActivity().startActivity(intent); - } - - public void onSettingsClicked() { - final Intent intent = new Intent(getActivity(), SettingsActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP); - requireActivity().startActivity(intent); - } - - private class BaseLogoutListener implements CommonsApplication.LogoutListener { - - @Override - public void onLogoutComplete() { - Timber.d("Logout complete callback received."); - final Intent nearbyIntent = new Intent( - getContext(), LoginActivity.class); - nearbyIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); - nearbyIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(nearbyIntent); - requireActivity().finish(); - } - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.kt b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.kt new file mode 100644 index 000000000..96baf9e5e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.kt @@ -0,0 +1,151 @@ +package fr.free.nrw.commons.navtab + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import fr.free.nrw.commons.AboutActivity +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.LoginActivity +import fr.free.nrw.commons.databinding.FragmentMoreBottomSheetLoggedOutBinding +import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.logging.CommonsLogSender +import fr.free.nrw.commons.settings.SettingsActivity +import javax.inject.Inject +import javax.inject.Named +import timber.log.Timber + + +class MoreBottomSheetLoggedOutFragment : BottomSheetDialogFragment() { + + private var binding: FragmentMoreBottomSheetLoggedOutBinding? = null + + @Inject + lateinit var commonsLogSender: CommonsLogSender + + @Inject + @field: Named("default_preferences") + lateinit var applicationKvStore: JsonKvStore + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentMoreBottomSheetLoggedOutBinding.inflate( + inflater, + container, + false + ) + return binding?.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle? + ) { + binding?.apply { + moreLogin.setOnClickListener { onLogoutClicked() } + moreFeedback.setOnClickListener { onFeedbackClicked() } + moreAbout.setOnClickListener { onAboutClicked() } + moreSettings.setOnClickListener { onSettingsClicked() } + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + override fun onAttach(context: Context) { + super.onAttach(context) + ApplicationlessInjection + .getInstance(requireActivity().applicationContext) + .commonsApplicationComponent + .inject(this) + } + + fun onLogoutClicked() { + applicationKvStore.putBoolean("login_skipped", false) + val intent = Intent(context, LoginActivity::class.java) + requireActivity().finish() // Kill the activity from which you will go to next activity + startActivity(intent) + } + + fun onFeedbackClicked() { + showAlertDialog() + } + + /** + * This method shows the alert dialog when a user wants to send feedback about the app. + */ + private fun showAlertDialog() { + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.feedback_sharing_data_alert) + .setCancelable(false) + .setPositiveButton(R.string.ok) { _, _ -> sendFeedback() } + .show() + } + + /** + * This method collects the feedback message and starts an activity with an implicit intent to + * the available email client. + */ + @SuppressLint("IntentReset") + private fun sendFeedback() { + val technicalInfo = commonsLogSender.getExtraInfo() + + val feedbackIntent = Intent(Intent.ACTION_SENDTO).apply { + type = "message/rfc822" + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(CommonsApplication.FEEDBACK_EMAIL)) + putExtra(Intent.EXTRA_SUBJECT, CommonsApplication.FEEDBACK_EMAIL_SUBJECT) + putExtra( + Intent.EXTRA_TEXT, + "\n\n${CommonsApplication.FEEDBACK_EMAIL_TEMPLATE_HEADER}\n$technicalInfo" + ) + } + + try { + startActivity(feedbackIntent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(activity, R.string.no_email_client, Toast.LENGTH_SHORT).show() + } + } + + fun onAboutClicked() { + val intent = Intent(activity, AboutActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + requireActivity().startActivity(intent) + } + + fun onSettingsClicked() { + val intent = Intent(activity, SettingsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + requireActivity().startActivity(intent) + } + + private inner class BaseLogoutListener : CommonsApplication.LogoutListener { + + override fun onLogoutComplete() { + Timber.d("Logout complete callback received.") + val nearbyIntent = Intent(context, LoginActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(nearbyIntent) + requireActivity().finish() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTab.java b/app/src/main/java/fr/free/nrw/commons/navtab/NavTab.java deleted file mode 100644 index 0a3123c1c..000000000 --- a/app/src/main/java/fr/free/nrw/commons/navtab/NavTab.java +++ /dev/null @@ -1,95 +0,0 @@ -package fr.free.nrw.commons.navtab; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.fragment.app.Fragment; - -import fr.free.nrw.commons.bookmarks.BookmarkFragment; -import fr.free.nrw.commons.contributions.ContributionsFragment; -import fr.free.nrw.commons.explore.ExploreFragment; -import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment; -import fr.free.nrw.commons.wikidata.model.EnumCode; -import fr.free.nrw.commons.wikidata.model.EnumCodeMap; - -import fr.free.nrw.commons.R; - - -public enum NavTab implements EnumCode { - CONTRIBUTIONS(R.string.contributions_fragment, R.drawable.ic_baseline_person_24) { - @NonNull - @Override - public Fragment newInstance() { - return ContributionsFragment.newInstance(); - } - }, - NEARBY(R.string.nearby_fragment, R.drawable.ic_location_on_black_24dp) { - @NonNull - @Override - public Fragment newInstance() { - return NearbyParentFragment.newInstance(); - } - }, - EXPLORE(R.string.navigation_item_explore, R.drawable.ic_globe) { - @NonNull - @Override - public Fragment newInstance() { - return ExploreFragment.newInstance(); - } - }, - BOOKMARKS(R.string.bookmarks, R.drawable.ic_round_star_border_24px) { - @NonNull - @Override - public Fragment newInstance() { - return BookmarkFragment.newInstance(); - } - }, - MORE(R.string.more, R.drawable.ic_menu_black_24dp) { - @NonNull - @Override - public Fragment newInstance() { - return null; - } - }; - - private static final EnumCodeMap MAP = new EnumCodeMap<>(NavTab.class); - - @StringRes - private final int text; - @DrawableRes - private final int icon; - - @NonNull - public static NavTab of(int code) { - return MAP.get(code); - } - - public static int size() { - return MAP.size(); - } - - @StringRes - public int text() { - return text; - } - - @DrawableRes - public int icon() { - return icon; - } - - @NonNull - public abstract Fragment newInstance(); - - @Override - public int code() { - // This enumeration is not marshalled so tying declaration order to presentation order is - // convenient and consistent. - return ordinal(); - } - - NavTab(@StringRes int text, @DrawableRes int icon) { - this.text = text; - this.icon = icon; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTab.kt b/app/src/main/java/fr/free/nrw/commons/navtab/NavTab.kt new file mode 100644 index 000000000..4573fccad --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/navtab/NavTab.kt @@ -0,0 +1,79 @@ +package fr.free.nrw.commons.navtab + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment + +import fr.free.nrw.commons.bookmarks.BookmarkFragment +import fr.free.nrw.commons.contributions.ContributionsFragment +import fr.free.nrw.commons.explore.ExploreFragment +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment +import fr.free.nrw.commons.wikidata.model.EnumCode +import fr.free.nrw.commons.wikidata.model.EnumCodeMap + +import fr.free.nrw.commons.R + + +enum class NavTab( + @StringRes private val text: Int, + @DrawableRes private val icon: Int +) : EnumCode { + + CONTRIBUTIONS(R.string.contributions_fragment, R.drawable.ic_baseline_person_24) { + override fun newInstance(): Fragment { + return ContributionsFragment.newInstance() + } + }, + NEARBY(R.string.nearby_fragment, R.drawable.ic_location_on_black_24dp) { + override fun newInstance(): Fragment { + return NearbyParentFragment.newInstance() + } + }, + EXPLORE(R.string.navigation_item_explore, R.drawable.ic_globe) { + override fun newInstance(): Fragment { + return ExploreFragment.newInstance() + } + }, + BOOKMARKS(R.string.bookmarks, R.drawable.ic_round_star_border_24px) { + override fun newInstance(): Fragment { + return BookmarkFragment.newInstance() + } + }, + MORE(R.string.more, R.drawable.ic_menu_black_24dp) { + override fun newInstance(): Fragment? { + return null + } + }; + + companion object { + private val MAP: EnumCodeMap = EnumCodeMap(NavTab::class.java) + + @JvmStatic + fun of(code: Int): NavTab { + return MAP[code] + } + + @JvmStatic + fun size(): Int { + return MAP.size() + } + } + + @StringRes + fun text(): Int { + return text + } + + @DrawableRes + fun icon(): Int { + return icon + } + + abstract fun newInstance(): Fragment? + + override fun code(): Int { + // This enumeration is not marshalled so tying declaration order to presentation order is + // convenient and consistent. + return ordinal + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabFragmentPagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabFragmentPagerAdapter.java deleted file mode 100644 index 5384f2e01..000000000 --- a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabFragmentPagerAdapter.java +++ /dev/null @@ -1,38 +0,0 @@ -package fr.free.nrw.commons.navtab; - -import android.view.ViewGroup; - -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentPagerAdapter; - -public class NavTabFragmentPagerAdapter extends FragmentPagerAdapter { - - private Fragment currentFragment; - - public NavTabFragmentPagerAdapter(FragmentManager mgr) { - super(mgr); - } - - @Nullable - public Fragment getCurrentFragment() { - return currentFragment; - } - - @Override - public Fragment getItem(int pos) { - return NavTab.of(pos).newInstance(); - } - - @Override - public int getCount() { - return NavTab.size(); - } - - @Override - public void setPrimaryItem(ViewGroup container, int position, Object object) { - currentFragment = ((Fragment) object); - super.setPrimaryItem(container, position, object); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabFragmentPagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabFragmentPagerAdapter.kt new file mode 100644 index 000000000..369c39ed6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabFragmentPagerAdapter.kt @@ -0,0 +1,36 @@ +package fr.free.nrw.commons.navtab + +import android.view.ViewGroup + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter + + +class NavTabFragmentPagerAdapter( + mgr: FragmentManager +) : FragmentPagerAdapter(mgr, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + + private var currentFragment: Fragment? = null + + fun getCurrentFragment(): Fragment? { + return currentFragment + } + + override fun getItem(pos: Int): Fragment { + return NavTab.of(pos).newInstance()!! + } + + override fun getCount(): Int { + return NavTab.size() + } + + override fun setPrimaryItem( + container: ViewGroup, + position: Int, + `object`: Any + ) { + currentFragment = `object` as Fragment + super.setPrimaryItem(container, position, `object`) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.java b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.java deleted file mode 100644 index 399cbc789..000000000 --- a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.java +++ /dev/null @@ -1,41 +0,0 @@ -package fr.free.nrw.commons.navtab; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.Menu; - -import com.google.android.material.bottomnavigation.BottomNavigationView; -import fr.free.nrw.commons.contributions.MainActivity; - - -public class NavTabLayout extends BottomNavigationView { - - public NavTabLayout(Context context) { - super(context); - setTabViews(); - } - - public NavTabLayout(Context context, AttributeSet attrs) { - super(context, attrs); - setTabViews(); - } - - public NavTabLayout(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - setTabViews(); - } - - private void setTabViews() { - if (((MainActivity) getContext()).applicationKvStore.getBoolean("login_skipped") == true) { - for (int i = 0; i < NavTabLoggedOut.size(); i++) { - NavTabLoggedOut navTab = NavTabLoggedOut.of(i); - getMenu().add(Menu.NONE, i, i, navTab.text()).setIcon(navTab.icon()); - } - } else { - for (int i = 0; i < NavTab.size(); i++) { - NavTab navTab = NavTab.of(i); - getMenu().add(Menu.NONE, i, i, navTab.text()).setIcon(navTab.icon()); - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt new file mode 100644 index 000000000..8d5298cac --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt @@ -0,0 +1,47 @@ +package fr.free.nrw.commons.navtab + +import android.content.Context +import android.util.AttributeSet +import android.view.Menu + +import com.google.android.material.bottomnavigation.BottomNavigationView +import fr.free.nrw.commons.contributions.MainActivity + + +class NavTabLayout : BottomNavigationView { + + constructor(context: Context) : super(context) { + setTabViews() + } + + constructor( + context: Context, + attrs: AttributeSet? + ) : super(context, attrs) { + setTabViews() + } + + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int + ) : super(context, attrs, defStyleAttr) { + setTabViews() + } + + private fun setTabViews() { + val isLoginSkipped = (context as MainActivity) + .applicationKvStore.getBoolean("login_skipped") + if (isLoginSkipped) { + for (i in 0 until NavTabLoggedOut.size()) { + val navTab = NavTabLoggedOut.of(i) + menu.add(Menu.NONE, i, i, navTab.text()).setIcon(navTab.icon()) + } + } else { + for (i in 0 until NavTab.size()) { + val navTab = NavTab.of(i) + menu.add(Menu.NONE, i, i, navTab.text()).setIcon(navTab.icon()) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLoggedOut.java b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLoggedOut.java deleted file mode 100644 index dc1c7ce6b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLoggedOut.java +++ /dev/null @@ -1,79 +0,0 @@ -package fr.free.nrw.commons.navtab; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.fragment.app.Fragment; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.bookmarks.BookmarkFragment; -import fr.free.nrw.commons.explore.ExploreFragment; -import fr.free.nrw.commons.wikidata.model.EnumCode; -import fr.free.nrw.commons.wikidata.model.EnumCodeMap; - - -public enum NavTabLoggedOut implements EnumCode { - - EXPLORE(R.string.navigation_item_explore, R.drawable.ic_globe) { - @NonNull - @Override - public Fragment newInstance() { - return ExploreFragment.newInstance(); - } - }, - BOOKMARKS(R.string.bookmarks, R.drawable.ic_round_star_border_24px) { - @NonNull - @Override - public Fragment newInstance() { - return BookmarkFragment.newInstance(); - } - }, - MORE(R.string.more, R.drawable.ic_menu_black_24dp) { - @NonNull - @Override - public Fragment newInstance() { - return null; - } - }; - - private static final EnumCodeMap MAP = new EnumCodeMap<>( - NavTabLoggedOut.class); - - @StringRes - private final int text; - @DrawableRes - private final int icon; - - @NonNull - public static NavTabLoggedOut of(int code) { - return MAP.get(code); - } - - public static int size() { - return MAP.size(); - } - - @StringRes - public int text() { - return text; - } - - @DrawableRes - public int icon() { - return icon; - } - - @NonNull - public abstract Fragment newInstance(); - - @Override - public int code() { - // This enumeration is not marshalled so tying declaration order to presentation order is - // convenient and consistent. - return ordinal(); - } - - NavTabLoggedOut(@StringRes int text, @DrawableRes int icon) { - this.text = text; - this.icon = icon; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLoggedOut.kt b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLoggedOut.kt new file mode 100644 index 000000000..ad73f1bbd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLoggedOut.kt @@ -0,0 +1,65 @@ +package fr.free.nrw.commons.navtab + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import fr.free.nrw.commons.R +import fr.free.nrw.commons.bookmarks.BookmarkFragment +import fr.free.nrw.commons.explore.ExploreFragment +import fr.free.nrw.commons.wikidata.model.EnumCode +import fr.free.nrw.commons.wikidata.model.EnumCodeMap + + +enum class NavTabLoggedOut( + @StringRes private val text: Int, + @DrawableRes private val icon: Int +) : EnumCode { + + EXPLORE(R.string.navigation_item_explore, R.drawable.ic_globe) { + override fun newInstance(): Fragment { + return ExploreFragment.newInstance() + } + }, + BOOKMARKS(R.string.bookmarks, R.drawable.ic_round_star_border_24px) { + override fun newInstance(): Fragment { + return BookmarkFragment.newInstance() + } + }, + MORE(R.string.more, R.drawable.ic_menu_black_24dp) { + override fun newInstance(): Fragment? { + return null + } + }; + + companion object { + private val MAP: EnumCodeMap = EnumCodeMap(NavTabLoggedOut::class.java) + + @JvmStatic + fun of(code: Int): NavTabLoggedOut { + return MAP[code] + } + + @JvmStatic + fun size(): Int { + return MAP.size() + } + } + + @StringRes + fun text(): Int { + return text + } + + @DrawableRes + fun icon(): Int { + return icon + } + + abstract fun newInstance(): Fragment? + + override fun code(): Int { + // This enumeration is not marshalled so tying declaration order to presentation order is + // convenient and consistent. + return ordinal + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyFilterSearchRecyclerViewAdapter.java b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyFilterSearchRecyclerViewAdapter.java index 5d480f4f7..b5f760c9f 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyFilterSearchRecyclerViewAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyFilterSearchRecyclerViewAdapter.java @@ -17,6 +17,7 @@ import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import fr.free.nrw.commons.R; +import java.util.Locale; public class NearbyFilterSearchRecyclerViewAdapter extends RecyclerView.Adapter @@ -121,11 +122,11 @@ public class NearbyFilterSearchRecyclerViewAdapter results.count = labels.size(); results.values = labels; } else { - constraint = constraint.toString().toLowerCase(); + constraint = constraint.toString().toLowerCase(Locale.ROOT); for (Label label : labels) { String data = label.toString(); - if (data.toLowerCase().startsWith(constraint.toString())) { + if (data.toLowerCase(Locale.ROOT).startsWith(constraint.toString())) { filteredArrayList.add(Label.fromText(label.getText())); } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt index 5152ac0f7..a4ea3cd5b 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt @@ -1,5 +1,6 @@ package fr.free.nrw.commons.nearby +import android.content.Intent import android.view.View import android.view.View.GONE import android.view.View.INVISIBLE @@ -17,9 +18,9 @@ import fr.free.nrw.commons.databinding.ItemPlaceBinding fun placeAdapterDelegate( bookmarkLocationDao: BookmarkLocationsDao, onItemClick: ((Place) -> Unit)? = null, - onCameraClicked: (Place, ActivityResultLauncher>) -> Unit, + onCameraClicked: (Place, ActivityResultLauncher>, ActivityResultLauncher) -> Unit, onCameraLongPressed: () -> Boolean, - onGalleryClicked: (Place) -> Unit, + onGalleryClicked: (Place, ActivityResultLauncher) -> Unit, onGalleryLongPressed: () -> Boolean, onBookmarkClicked: (Place, Boolean) -> Unit, onBookmarkLongPressed: () -> Boolean, @@ -28,6 +29,8 @@ fun placeAdapterDelegate( onDirectionsClicked: (Place) -> Unit, onDirectionsLongPressed: () -> Boolean, inAppCameraLocationPermissionLauncher: ActivityResultLauncher>, + cameraPickLauncherForResult: ActivityResultLauncher, + galleryPickLauncherForResult: ActivityResultLauncher ) = adapterDelegateViewBinding({ layoutInflater, parent -> ItemPlaceBinding.inflate(layoutInflater, parent, false) }) { @@ -44,10 +47,10 @@ fun placeAdapterDelegate( onItemClick?.invoke(item) } } - nearbyButtonLayout.cameraButton.setOnClickListener { onCameraClicked(item, inAppCameraLocationPermissionLauncher) } + nearbyButtonLayout.cameraButton.setOnClickListener { onCameraClicked(item, inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult) } nearbyButtonLayout.cameraButton.setOnLongClickListener { onCameraLongPressed() } - nearbyButtonLayout.galleryButton.setOnClickListener { onGalleryClicked(item) } + nearbyButtonLayout.galleryButton.setOnClickListener { onGalleryClicked(item, galleryPickLauncherForResult) } nearbyButtonLayout.galleryButton.setOnLongClickListener { onGalleryLongPressed() } bookmarkButtonImage.setOnClickListener { val isBookmarked = bookmarkLocationDao.updateBookmarkLocation(item) diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceDao.java b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceDao.java index 8c0a0a393..9e4292114 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceDao.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceDao.java @@ -37,8 +37,21 @@ public abstract class PlaceDao { */ public Completable save(final Place place) { return Completable - .fromAction(() -> { - saveSynchronous(place); - }); + .fromAction(() -> saveSynchronous(place)); + } + + /** + * Deletes all Place objects from the database. + */ + @Query("DELETE FROM place") + public abstract void deleteAllSynchronous(); + + /** + * Deletes all Place objects from the database. + * + * @return A Completable that completes once the deletion operation is done. + */ + public Completable deleteAll() { + return Completable.fromAction(this::deleteAllSynchronous); } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlacesLocalDataSource.java b/app/src/main/java/fr/free/nrw/commons/nearby/PlacesLocalDataSource.java index a7f1dadcd..86a57eadc 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlacesLocalDataSource.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlacesLocalDataSource.java @@ -35,4 +35,8 @@ public class PlacesLocalDataSource { public Completable savePlace(Place place) { return placeDao.save(place); } + + public Completable clearCache() { + return placeDao.deleteAll(); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlacesRepository.java b/app/src/main/java/fr/free/nrw/commons/nearby/PlacesRepository.java index 85e964ddb..846e54fac 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlacesRepository.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlacesRepository.java @@ -3,6 +3,7 @@ package fr.free.nrw.commons.nearby; import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.location.LatLng; import io.reactivex.Completable; +import io.reactivex.schedulers.Schedulers; import javax.inject.Inject; /** @@ -38,4 +39,13 @@ public class PlacesRepository { return localDataSource.fetchPlace(entityID); } + /** + * Clears the Nearby cache on an IO thread. + * + * @return A Completable that completes once the cache has been successfully cleared. + */ + public Completable clearCache() { + return localDataSource.clearCache() + .subscribeOn(Schedulers.io()); // Ensure it runs on IO thread + } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt b/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt index d238296d1..299ac4b6e 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt @@ -87,7 +87,7 @@ class WikidataFeedback : BaseActivity() { lat, lng, ) - } as Callable>, + }, ).subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ aBoolean: Boolean? -> diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/CommonPlaceClickActions.kt b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/CommonPlaceClickActions.kt index f3eecf116..a4d6b14b7 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/CommonPlaceClickActions.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/CommonPlaceClickActions.kt @@ -28,14 +28,14 @@ class CommonPlaceClickActions private val activity: Activity, private val contributionController: ContributionController, ) { - fun onCameraClicked(): (Place, ActivityResultLauncher>) -> Unit = - { place, launcher -> + fun onCameraClicked(): (Place, ActivityResultLauncher>, ActivityResultLauncher) -> Unit = + { place, launcher, resultLauncher -> if (applicationKvStore.getBoolean("login_skipped", false)) { showLoginDialog() } else { Timber.d("Camera button tapped. Image title: ${place.getName()}Image desc: ${place.longDescription}") storeSharedPrefs(place) - contributionController.initiateCameraPick(activity, launcher) + contributionController.initiateCameraPick(activity, launcher, resultLauncher) } } @@ -72,14 +72,14 @@ class CommonPlaceClickActions true } - fun onGalleryClicked(): (Place) -> Unit = - { + fun onGalleryClicked(): (Place, ActivityResultLauncher) -> Unit = + {place, galleryPickLauncherForResult -> if (applicationKvStore.getBoolean("login_skipped", false)) { showLoginDialog() } else { - Timber.d("Gallery button tapped. Image title: ${it.getName()}Image desc: ${it.getLongDescription()}") - storeSharedPrefs(it) - contributionController.initiateGalleryPick(activity, false) + Timber.d("Gallery button tapped. Image title: ${place.getName()}Image desc: ${place.getLongDescription()}") + storeSharedPrefs(place) + contributionController.initiateGalleryPick(activity, galleryPickLauncherForResult, false) } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java index 8a3c0c330..fff4e4ca7 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java @@ -48,10 +48,12 @@ import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions; import androidx.activity.result.contract.ActivityResultContracts.RequestPermission; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog.Builder; +import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import androidx.recyclerview.widget.DividerItemDecoration; @@ -105,6 +107,7 @@ import fr.free.nrw.commons.utils.NetworkUtils; import fr.free.nrw.commons.utils.SystemThemeUtils; import fr.free.nrw.commons.utils.ViewUtil; import fr.free.nrw.commons.wikidata.WikidataEditListener; +import io.reactivex.Completable; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; @@ -218,9 +221,36 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment private LatLng updatedLatLng; private boolean searchable; + private ConstraintLayout nearbyLegend; + private GridLayoutManager gridLayoutManager; private List dataList; private BottomSheetAdapter bottomSheetAdapter; + + private final ActivityResultLauncher galleryPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks); + }); + }); + + private final ActivityResultLauncher customSelectorLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromCustomSelector(result, requireActivity(), callbacks); + }); + }); + + private final ActivityResultLauncher cameraPickLauncherForResult = + registerForActivityResult(new StartActivityForResult(), + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks); + }); + }); + private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult( new RequestMultiplePermissions(), new ActivityResultCallback>() { @@ -236,7 +266,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } else { if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { controller.handleShowRationaleFlowCameraLocation(getActivity(), - inAppCameraLocationPermissionLauncher); + inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); } else { controller.locationPermissionCallback.onLocationPermissionDenied( getActivity().getString( @@ -261,8 +291,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment askForLocationPermission(); }, null, - null, - false); + null + ); } else { if (isPermissionDenied) { locationPermissionsHelper.showAppSettingsDialog(getActivity(), @@ -302,6 +332,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment progressDialog.setCancelable(false); progressDialog.setMessage("Saving in progress..."); setHasOptionsMenu(true); + // Inflate the layout for this fragment return view; @@ -311,9 +342,21 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.nearby_fragment_menu, menu); + MenuItem refreshButton = menu.findItem(R.id.item_refresh); MenuItem listMenu = menu.findItem(R.id.list_sheet); MenuItem saveAsGPXButton = menu.findItem(R.id.list_item_gpx); MenuItem saveAsKMLButton = menu.findItem(R.id.list_item_kml); + refreshButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + try { + emptyCache(); + } catch (Exception e) { + throw new RuntimeException(e); + } + return false; + } + }); listMenu.setOnMenuItemClickListener(new OnMenuItemClickListener() { @Override public boolean onMenuItemClick(MenuItem item) { @@ -362,6 +405,16 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } locationPermissionsHelper = new LocationPermissionsHelper(getActivity(), locationManager, this); + + // Set up the floating activity button to toggle the visibility of the legend + binding.fabLegend.setOnClickListener(v -> { + if (binding.nearbyLegendLayout.getRoot().getVisibility() == View.VISIBLE) { + binding.nearbyLegendLayout.getRoot().setVisibility(View.GONE); + } else { + binding.nearbyLegendLayout.getRoot().setVisibility(View.VISIBLE); + } + }); + presenter.attachView(this); isPermissionDenied = false; recenterToUserLocation = false; @@ -475,7 +528,9 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment final Bundle bundle = new Bundle(); try { bundle.putString("query", - FileUtils.readFromResource("/queries/radius_query_for_upload_wizard.rq")); + FileUtils.INSTANCE.readFromResource( + "/queries/radius_query_for_upload_wizard.rq") + ); } catch (IOException e) { Timber.e(e); } @@ -555,7 +610,9 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment return Unit.INSTANCE; }, commonPlaceClickActions, - inAppCameraLocationPermissionLauncher + inAppCameraLocationPermissionLauncher, + galleryPickLauncherForResult, + cameraPickLauncherForResult ); binding.bottomSheetNearby.rvNearbyList.setAdapter(adapter); } @@ -645,7 +702,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment = new LatLng(Double.parseDouble(locationLatLng[0]), Double.parseDouble(locationLatLng[1]), 1f); } else { - lastKnownLocation = MapUtils.defaultLatLng; + lastKnownLocation = MapUtils.getDefaultLatLng(); } if (binding.map != null) { moveCameraToPosition( @@ -737,7 +794,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment hideBottomSheet(); binding.nearbyFilter.searchViewLayout.searchView.setOnQueryTextFocusChangeListener( (v, hasFocus) -> { - LayoutUtils.setLayoutHeightAllignedToWidth(1.25, + LayoutUtils.setLayoutHeightAlignedToWidth(1.25, binding.nearbyFilterList.getRoot()); if (hasFocus) { binding.nearbyFilterList.getRoot().setVisibility(View.VISIBLE); @@ -778,7 +835,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment .getLayoutParams().width = (int) LayoutUtils.getScreenWidth(getActivity(), 0.75); binding.nearbyFilterList.searchListView.setAdapter(nearbyFilterSearchRecyclerViewAdapter); - LayoutUtils.setLayoutHeightAllignedToWidth(1.25, binding.nearbyFilterList.getRoot()); + LayoutUtils.setLayoutHeightAlignedToWidth(1.25, binding.nearbyFilterList.getRoot()); compositeDisposable.add( RxSearchView.queryTextChanges(binding.nearbyFilter.searchViewLayout.searchView) .takeUntil(RxView.detaches(binding.nearbyFilter.searchViewLayout.searchView)) @@ -910,6 +967,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment lastPlaceToCenter.location.getLatitude() - cameraShift, lastPlaceToCenter.getLocation().getLongitude(), 0)); } + highlightNearestPlace(place); } @@ -1115,6 +1173,48 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } } + /** + * Reloads the Nearby map + * Clears all location markers, refreshes them, reinserts them into the map. + * + */ + private void reloadMap() { + clearAllMarkers(); // Clear the list of markers + binding.map.getController().setZoom(ZOOM_LEVEL); // Reset the zoom level + binding.map.getController().setCenter(lastMapFocus); // Recenter the focus + if (locationPermissionsHelper.checkLocationPermission(getActivity())) { + locationPermissionGranted(); // Reload map with user's location + } else { + startMapWithoutPermission(); // Reload map without user's location + } + binding.map.invalidate(); // Invalidate the map + presenter.updateMapAndList(LOCATION_SIGNIFICANTLY_CHANGED); // Restart the map + Timber.d("Reloaded Map Successfully"); + } + + + /** + * Clears the Nearby local cache and then calls for the map to be reloaded + * + */ + private void emptyCache() { + // reload the map once the cache is cleared + compositeDisposable.add( + placesRepository.clearCache() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .andThen(Completable.fromAction(this::reloadMap)) + .subscribe( + () -> { + Timber.d("Nearby Cache cleared successfully."); + }, + throwable -> { + Timber.e(throwable, "Failed to clear the Nearby Cache"); + } + ) + ); + } + private void savePlacesAsKML() { final Observable savePlacesObservable = Observable .fromCallable(() -> nearbyController @@ -1904,7 +2004,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment * * @param nearestPlace nearest place, which has to be highlighted */ - private void highlightNearestPlace(Place nearestPlace) { + private void highlightNearestPlace(final Place nearestPlace) { + binding.bottomSheetDetails.icon.setVisibility(View.VISIBLE); passInfoToSheet(nearestPlace); hideBottomSheet(); bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); @@ -1918,32 +2019,37 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment * @return returns the drawable of marker according to the place information */ private @DrawableRes int getIconFor(Place place, Boolean isBookmarked) { - if (nearestPlace != null) { - if (place.name.equals(nearestPlace.name)) { - // Highlight nearest place only when user clicks on the home nearby banner - highlightNearestPlace(place); - return (isBookmarked ? - R.drawable.ic_custom_map_marker_purple_bookmarked : - R.drawable.ic_custom_map_marker_purple); - } + if (nearestPlace != null && place.name.equals(nearestPlace.name)) { + // Highlight nearest place only when user clicks on the home nearby banner +// highlightNearestPlace(place); + return (isBookmarked ? + R.drawable.ic_custom_map_marker_purple_bookmarked : + R.drawable.ic_custom_map_marker_purple + ); } + if (place.isMonument()) { return R.drawable.ic_custom_map_marker_monuments; - } else if (!place.pic.trim().isEmpty()) { + } + if (!place.pic.trim().isEmpty()) { return (isBookmarked ? R.drawable.ic_custom_map_marker_green_bookmarked : - R.drawable.ic_custom_map_marker_green); - } else if (!place.exists) { // Means that the topic of the Wikidata item does not exist in the real world anymore, for instance it is a past event, or a place that was destroyed + R.drawable.ic_custom_map_marker_green + ); + } + if (!place.exists) { // Means that the topic of the Wikidata item does not exist in the real world anymore, for instance it is a past event, or a place that was destroyed return (R.drawable.ic_clear_black_24dp); - }else if (place.name == "") { + } + if (place.name.isEmpty()) { return (isBookmarked ? R.drawable.ic_custom_map_marker_grey_bookmarked : - R.drawable.ic_custom_map_marker_grey); - } else { - return (isBookmarked ? - R.drawable.ic_custom_map_marker_red_bookmarked : - R.drawable.ic_custom_map_marker_red); + R.drawable.ic_custom_map_marker_grey + ); } + return (isBookmarked ? + R.drawable.ic_custom_map_marker_red_bookmarked : + R.drawable.ic_custom_map_marker_red + ); } /** @@ -2186,7 +2292,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment if (binding.fabCamera.isShown()) { Timber.d("Camera button tapped. Place: %s", selectedPlace.toString()); storeSharedPrefs(selectedPlace); - controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher); + controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); } }); @@ -2195,6 +2301,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment Timber.d("Gallery button tapped. Place: %s", selectedPlace.toString()); storeSharedPrefs(selectedPlace); controller.initiateGalleryPick(getActivity(), + galleryPickLauncherForResult, false); } }); @@ -2203,7 +2310,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment if (binding.fabCustomGallery.isShown()) { Timber.d("Gallery button tapped. Place: %s", selectedPlace.toString()); storeSharedPrefs(selectedPlace); - controller.initiateCustomGalleryPickWithPermission(getActivity()); + controller.initiateCustomGalleryPickWithPermission(getActivity(), customSelectorLauncherForResult); } }); } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/PlaceAdapter.kt b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/PlaceAdapter.kt index 689aa7efc..e5cc92667 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/PlaceAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/PlaceAdapter.kt @@ -1,5 +1,6 @@ package fr.free.nrw.commons.nearby.fragments +import android.content.Intent import androidx.activity.result.ActivityResultLauncher import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao import fr.free.nrw.commons.nearby.Place @@ -12,6 +13,8 @@ class PlaceAdapter( onBookmarkClicked: (Place, Boolean) -> Unit, commonPlaceClickActions: CommonPlaceClickActions, inAppCameraLocationPermissionLauncher: ActivityResultLauncher>, + galleryPickLauncherForResult: ActivityResultLauncher, + cameraPickLauncherForResult: ActivityResultLauncher ) : BaseDelegateAdapter( placeAdapterDelegate( bookmarkLocationsDao, @@ -27,6 +30,8 @@ class PlaceAdapter( commonPlaceClickActions.onDirectionsClicked(), commonPlaceClickActions.onDirectionsLongPressed(), inAppCameraLocationPermissionLauncher, + cameraPickLauncherForResult, + galleryPickLauncherForResult ), areItemsTheSame = { oldItem, newItem -> oldItem.wikiDataEntityId == newItem.wikiDataEntityId }, ) diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/presenter/NearbyParentFragmentPresenter.java b/app/src/main/java/fr/free/nrw/commons/nearby/presenter/NearbyParentFragmentPresenter.java index 410aeb9f4..00a491e68 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/presenter/NearbyParentFragmentPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/presenter/NearbyParentFragmentPresenter.java @@ -11,13 +11,10 @@ import static fr.free.nrw.commons.nearby.CheckBoxTriStates.UNKNOWN; import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; import android.location.Location; -import android.view.View; import androidx.annotation.MainThread; import androidx.annotation.Nullable; -import androidx.work.ExistingWorkPolicy; import fr.free.nrw.commons.BaseMarker; import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao; -import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.location.LatLng; import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType; @@ -26,14 +23,10 @@ import fr.free.nrw.commons.nearby.CheckBoxTriStates; import fr.free.nrw.commons.nearby.Label; import fr.free.nrw.commons.nearby.MarkerPlaceGroup; import fr.free.nrw.commons.nearby.NearbyController; -import fr.free.nrw.commons.nearby.NearbyFilterState; import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.nearby.PlaceDao; import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract; -import fr.free.nrw.commons.upload.worker.WorkRequestHelper; import fr.free.nrw.commons.utils.LocationUtils; import fr.free.nrw.commons.wikidata.WikidataEditListener; -import io.reactivex.disposables.CompositeDisposable; import java.lang.reflect.Proxy; import java.util.List; import timber.log.Timber; diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java deleted file mode 100644 index 572dd0317..000000000 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java +++ /dev/null @@ -1,288 +0,0 @@ -package fr.free.nrw.commons.notification; - -import android.annotation.SuppressLint; -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 androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import com.google.android.material.snackbar.Snackbar; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.databinding.ActivityNotificationBinding; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException; -import fr.free.nrw.commons.notification.models.Notification; -import fr.free.nrw.commons.notification.models.NotificationType; -import fr.free.nrw.commons.theme.BaseActivity; -import fr.free.nrw.commons.utils.NetworkUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import io.reactivex.Observable; -import io.reactivex.ObservableSource; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.Callable; -import javax.inject.Inject; -import kotlin.Unit; -import timber.log.Timber; - -/** - * Created by root on 18.12.2017. - */ - -public class NotificationActivity extends BaseActivity { - private ActivityNotificationBinding binding; - - @Inject - NotificationController controller; - - @Inject - SessionManager sessionManager; - - private static final String TAG_NOTIFICATION_WORKER_FRAGMENT = "NotificationWorkerFragment"; - private NotificationWorkerFragment mNotificationWorkerFragment; - private NotificatinAdapter adapter; - private List notificationList; - MenuItem notificationMenuItem; - /** - * Boolean isRead is true if this notification activity is for read section of notification. - */ - private boolean isRead; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - isRead = getIntent().getStringExtra("title").equals("read"); - binding = ActivityNotificationBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - mNotificationWorkerFragment = (NotificationWorkerFragment) getFragmentManager() - .findFragmentByTag(TAG_NOTIFICATION_WORKER_FRAGMENT); - initListView(); - setPageTitle(); - setSupportActionBar(binding.toolbar.toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - } - - @Override - public boolean onSupportNavigateUp() { - onBackPressed(); - return true; - } - - /** - * If this is unread section of the notifications, removeNotification method - * Marks the notification as read, - * Removes the notification from unread, - * Displays the Snackbar. - * - * Otherwise returns (read section). - * - * @param notification - */ - @SuppressLint("CheckResult") - public void removeNotification(Notification notification) { - if (isRead) { - return; - } - Disposable disposable = Observable.defer((Callable>) - () -> controller.markAsRead(notification)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - if (result) { - notificationList.remove(notification); - setItems(notificationList); - adapter.notifyDataSetChanged(); - ViewUtil.showLongSnackbar(binding.container,getString(R.string.notification_mark_read)); - if (notificationList.size() == 0) { - setEmptyView(); - binding.container.setVisibility(View.GONE); - binding.noNotificationBackground.setVisibility(View.VISIBLE); - } - } else { - adapter.notifyDataSetChanged(); - setItems(notificationList); - ViewUtil.showLongToast(this,getString(R.string.some_error)); - } - }, throwable -> { - if (throwable instanceof InvalidLoginTokenException) { - 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); - } else { - Timber.e(throwable, "Error occurred while loading notifications"); - throwable.printStackTrace(); - ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications); - binding.progressBar.setVisibility(View.GONE); - ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications); - } - binding.progressBar.setVisibility(View.GONE); - }); - compositeDisposable.add(disposable); - } - - - - private void initListView() { - binding.listView.setLayoutManager(new LinearLayoutManager(this)); - DividerItemDecoration itemDecor = new DividerItemDecoration(binding.listView.getContext(), DividerItemDecoration.VERTICAL); - binding.listView.addItemDecoration(itemDecor); - if (isRead) { - refresh(true); - } else { - refresh(false); - } - adapter = new NotificatinAdapter(item -> { - Timber.d("Notification clicked %s", item.getLink()); - if (item.getNotificationType() == NotificationType.EMAIL){ - ViewUtil.showLongSnackbar(binding.container,getString(R.string.check_your_email_inbox)); - } else { - handleUrl(item.getLink()); - } - removeNotification(item); - return Unit.INSTANCE; - }); - binding.listView.setAdapter(adapter); - } - - private void refresh(boolean archived) { - if (!NetworkUtils.isInternetConnectionEstablished(this)) { - binding.progressBar.setVisibility(View.GONE); - Snackbar.make(binding.container, R.string.no_internet, Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.retry, view -> refresh(archived)).show(); - } else { - addNotifications(archived); - } - binding.progressBar.setVisibility(View.VISIBLE); - binding.noNotificationBackground.setVisibility(View.GONE); - binding.container.setVisibility(View.VISIBLE); - } - - @SuppressLint("CheckResult") - private void addNotifications(boolean archived) { - Timber.d("Add notifications"); - if (mNotificationWorkerFragment == null) { - binding.progressBar.setVisibility(View.VISIBLE); - compositeDisposable.add(controller.getNotifications(archived) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(notificationList -> { - Collections.reverse(notificationList); - Timber.d("Number of notifications is %d", notificationList.size()); - this.notificationList = notificationList; - if (notificationList.size()==0){ - setEmptyView(); - binding.container.setVisibility(View.GONE); - binding.noNotificationBackground.setVisibility(View.VISIBLE); - } else { - setItems(notificationList); - } - binding.progressBar.setVisibility(View.GONE); - }, throwable -> { - Timber.e(throwable, "Error occurred while loading notifications "); - ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications); - binding.progressBar.setVisibility(View.GONE); - })); - } else { - notificationList = mNotificationWorkerFragment.getNotificationList(); - setItems(notificationList); - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.menu_notifications, menu); - notificationMenuItem = menu.findItem(R.id.archived); - setMenuItemTitle(); - return true; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - // Handle item selection - switch (item.getItemId()) { - case R.id.archived: - if (item.getTitle().equals(getString(R.string.menu_option_read))) { - NotificationActivity.startYourself(NotificationActivity.this, "read"); - }else if (item.getTitle().equals(getString(R.string.menu_option_unread))) { - onBackPressed(); - } - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - private void handleUrl(String url) { - if (url == null || url.equals("")) { - return; - } - Utils.handleWebUrl(this, Uri.parse(url)); - } - - private void setItems(List notificationList) { - if (notificationList == null || notificationList.isEmpty()) { - ViewUtil.showShortSnackbar(binding.container, R.string.no_notifications); - /*progressBar.setVisibility(View.GONE); - recyclerView.setVisibility(View.GONE);*/ - binding.container.setVisibility(View.GONE); - setEmptyView(); - binding.noNotificationBackground.setVisibility(View.VISIBLE); - return; - } - binding.container.setVisibility(View.VISIBLE); - binding.noNotificationBackground.setVisibility(View.GONE); - adapter.setItems(notificationList); - } - - public static void startYourself(Context context, String title) { - Intent intent = new Intent(context, NotificationActivity.class); - intent.putExtra("title", title); - - context.startActivity(intent); - } - - private void setPageTitle() { - if (getSupportActionBar() != null) { - if (isRead) { - getSupportActionBar().setTitle(R.string.read_notifications); - } else { - getSupportActionBar().setTitle(R.string.notifications); - } - } - } - - private void setEmptyView() { - if (isRead) { - binding.noNotificationText.setText(R.string.no_read_notification); - }else { - binding.noNotificationText.setText(R.string.no_notification); - } - } - - private void setMenuItemTitle() { - if (isRead) { - notificationMenuItem.setTitle(R.string.menu_option_unread); - - }else { - notificationMenuItem.setTitle(R.string.menu_option_read); - - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.kt b/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.kt new file mode 100644 index 000000000..1547f89ad --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.kt @@ -0,0 +1,247 @@ +package fr.free.nrw.commons.notification + +import android.annotation.SuppressLint +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.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import fr.free.nrw.commons.CommonsApplication +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.InvalidLoginTokenException +import fr.free.nrw.commons.databinding.ActivityNotificationBinding +import fr.free.nrw.commons.notification.models.Notification +import fr.free.nrw.commons.notification.models.NotificationType +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.NetworkUtils +import fr.free.nrw.commons.utils.ViewUtil +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import javax.inject.Inject + +/** + * Created by root on 18.12.2017. + */ +class NotificationActivity : BaseActivity() { + + private lateinit var binding: ActivityNotificationBinding + + @Inject + lateinit var controller: NotificationController + + @Inject + lateinit var sessionManager: SessionManager + + private val tagNotificationWorkerFragment = "NotificationWorkerFragment" + private var mNotificationWorkerFragment: NotificationWorkerFragment? = null + private lateinit var adapter: NotificationAdapter + private var notificationList: MutableList = mutableListOf() + private var notificationMenuItem: MenuItem? = null + + /** + * Boolean isRead is true if this notification activity is for read section of notification. + */ + private var isRead: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + isRead = intent.getStringExtra("title") == "read" + binding = ActivityNotificationBinding.inflate(layoutInflater) + setContentView(binding.root) + mNotificationWorkerFragment = supportFragmentManager.findFragmentByTag( + tagNotificationWorkerFragment + ) as? NotificationWorkerFragment + initListView() + setPageTitle() + setSupportActionBar(binding.toolbar.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + + @SuppressLint("CheckResult", "NotifyDataSetChanged") + fun removeNotification(notification: Notification) { + if (isRead) return + + val disposable = Observable.defer { controller.markAsRead(notification) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ result -> + if (result) { + notificationList.remove(notification) + setItems(notificationList) + adapter.notifyDataSetChanged() + ViewUtil.showLongSnackbar(binding.container, getString(R.string.notification_mark_read)) + if (notificationList.isEmpty()) { + setEmptyView() + binding.container.visibility = View.GONE + binding.noNotificationBackground.visibility = View.VISIBLE + } + } else { + adapter.notifyDataSetChanged() + setItems(notificationList) + ViewUtil.showLongToast(this, getString(R.string.some_error)) + } + }, { throwable -> + if (throwable is InvalidLoginTokenException) { + val username = sessionManager.userName + val logoutListener = CommonsApplication.BaseLogoutListener( + this, + getString(R.string.invalid_login_message), + username + ) + CommonsApplication.instance.clearApplicationData(this, logoutListener) + } else { + Timber.e(throwable, "Error occurred while loading notifications") + ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications) + } + binding.progressBar.visibility = View.GONE + }) + compositeDisposable.add(disposable) + } + + private fun initListView() { + binding.listView.layoutManager = LinearLayoutManager(this) + val itemDecor = DividerItemDecoration(binding.listView.context, DividerItemDecoration.VERTICAL) + binding.listView.addItemDecoration(itemDecor) + refresh(isRead) + adapter = NotificationAdapter { item -> + Timber.d("Notification clicked %s", item.link) + if (item.notificationType == NotificationType.EMAIL) { + ViewUtil.showLongSnackbar(binding.container, getString(R.string.check_your_email_inbox)) + } else { + handleUrl(item.link) + } + removeNotification(item) + } + binding.listView.adapter = adapter + } + + private fun refresh(archived: Boolean) { + if (!NetworkUtils.isInternetConnectionEstablished(this)) { + binding.progressBar.visibility = View.GONE + Snackbar.make(binding.container, R.string.no_internet, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.retry) { refresh(archived) } + .show() + } else { + addNotifications(archived) + } + binding.progressBar.visibility = View.VISIBLE + binding.noNotificationBackground.visibility = View.GONE + binding.container.visibility = View.VISIBLE + } + + @SuppressLint("CheckResult") + private fun addNotifications(archived: Boolean) { + Timber.d("Add notifications") + if (mNotificationWorkerFragment == null) { + binding.progressBar.visibility = View.VISIBLE + compositeDisposable.add(controller.getNotifications(archived) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ notificationList -> + notificationList.reversed() + Timber.d("Number of notifications is %d", notificationList.size) + this.notificationList = notificationList.toMutableList() + if (notificationList.isEmpty()) { + setEmptyView() + binding.container.visibility = View.GONE + binding.noNotificationBackground.visibility = View.VISIBLE + } else { + setItems(notificationList) + } + binding.progressBar.visibility = View.GONE + }, { throwable -> + Timber.e(throwable, "Error occurred while loading notifications") + ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications) + binding.progressBar.visibility = View.GONE + })) + } else { + notificationList = mNotificationWorkerFragment?.notificationList?.toMutableList() ?: mutableListOf() + setItems(notificationList) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_notifications, menu) + notificationMenuItem = menu.findItem(R.id.archived) + setMenuItemTitle() + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.archived -> { + if (item.title == getString(R.string.menu_option_read)) { + startYourself(this, "read") + } else if (item.title == getString(R.string.menu_option_unread)) { + onBackPressed() + } + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun handleUrl(url: String?) { + if (url.isNullOrEmpty()) return + Utils.handleWebUrl(this, Uri.parse(url)) + } + + private fun setItems(notificationList: List?) { + if (notificationList.isNullOrEmpty()) { + ViewUtil.showShortSnackbar(binding.container, R.string.no_notifications) + binding.container.visibility = View.GONE + setEmptyView() + binding.noNotificationBackground.visibility = View.VISIBLE + return + } + binding.container.visibility = View.VISIBLE + binding.noNotificationBackground.visibility = View.GONE + adapter.items = notificationList + } + + private fun setPageTitle() { + supportActionBar?.title = if (isRead) { + getString(R.string.read_notifications) + } else { + getString(R.string.notifications) + } + } + + private fun setEmptyView() { + binding.noNotificationText.text = if (isRead) { + getString(R.string.no_read_notification) + } else { + getString(R.string.no_notification) + } + } + + private fun setMenuItemTitle() { + notificationMenuItem?.title = if (isRead) { + getString(R.string.menu_option_unread) + } else { + getString(R.string.menu_option_read) + } + } + + companion object { + fun startYourself(context: Context, title: String) { + val intent = Intent(context, NotificationActivity::class.java) + intent.putExtra("title", title) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificatinAdapter.kt b/app/src/main/java/fr/free/nrw/commons/notification/NotificationAdapter.kt similarity index 92% rename from app/src/main/java/fr/free/nrw/commons/notification/NotificatinAdapter.kt rename to app/src/main/java/fr/free/nrw/commons/notification/NotificationAdapter.kt index 41d7d4883..637443ecf 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificatinAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationAdapter.kt @@ -3,7 +3,7 @@ package fr.free.nrw.commons.notification import fr.free.nrw.commons.notification.models.Notification import fr.free.nrw.commons.upload.categories.BaseDelegateAdapter -internal class NotificatinAdapter( +internal class NotificationAdapter( onNotificationClicked: (Notification) -> Unit, ) : BaseDelegateAdapter( notificationDelegate(onNotificationClicked), diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.kt b/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.kt index a0bf1176a..b5c4f4a7a 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.kt @@ -64,8 +64,8 @@ class NotificationClient return Notification( notificationType = notificationType, notificationText = notificationText, - date = DateUtil.getMonthOnlyDateString(timestamp), - link = contents?.links?.primary?.url ?: "", + date = DateUtil.getMonthOnlyDateString(getTimestamp()), + link = contents?.links?.getPrimary()?.url ?: "", iconUrl = "", notificationId = id().toString(), ) diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java deleted file mode 100644 index de1f372d2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java +++ /dev/null @@ -1,33 +0,0 @@ -package fr.free.nrw.commons.notification; - -import fr.free.nrw.commons.notification.models.Notification; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import io.reactivex.Observable; -import io.reactivex.Single; - -/** - * Created by root on 19.12.2017. - */ -@Singleton -public class NotificationController { - - private NotificationClient notificationClient; - - - @Inject - public NotificationController(NotificationClient notificationClient) { - this.notificationClient = notificationClient; - } - - public Single> getNotifications(boolean archived) { - return notificationClient.getNotifications(archived); - } - - Observable markAsRead(Notification notification) { - return notificationClient.markNotificationAsRead(notification.getNotificationId()); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.kt b/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.kt new file mode 100644 index 000000000..870d658cb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.kt @@ -0,0 +1,25 @@ +package fr.free.nrw.commons.notification + +import fr.free.nrw.commons.notification.models.Notification +import javax.inject.Inject +import javax.inject.Singleton + +import io.reactivex.Observable +import io.reactivex.Single + +/** + * Created by root on 19.12.2017. + */ +@Singleton +class NotificationController @Inject constructor( + private val notificationClient: NotificationClient +) { + + fun getNotifications(archived: Boolean): Single> { + return notificationClient.getNotifications(archived) + } + + fun markAsRead(notification: Notification): Observable { + return notificationClient.markNotificationAsRead(notification.notificationId) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationHelper.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationHelper.java deleted file mode 100644 index b63d3a4c1..000000000 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationHelper.java +++ /dev/null @@ -1,73 +0,0 @@ -package fr.free.nrw.commons.notification; - -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import androidx.core.app.NotificationCompat; -import javax.inject.Inject; -import javax.inject.Singleton; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.R; -import static androidx.core.app.NotificationCompat.DEFAULT_ALL; -import static androidx.core.app.NotificationCompat.PRIORITY_HIGH; - -/** - * Helper class that can be used to build a generic notification - * Going forward all notifications should be built using this helper class - */ -@Singleton -public class NotificationHelper { - - public static final int NOTIFICATION_DELETE = 1; - public static final int NOTIFICATION_EDIT_CATEGORY = 2; - public static final int NOTIFICATION_EDIT_COORDINATES = 3; - public static final int NOTIFICATION_EDIT_DESCRIPTION = 4; - public static final int NOTIFICATION_EDIT_DEPICTIONS = 5; - - private final NotificationManager notificationManager; - private final NotificationCompat.Builder notificationBuilder; - - @Inject - public NotificationHelper(final Context context) { - notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - notificationBuilder = new NotificationCompat - .Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL) - .setOnlyAlertOnce(true); - } - - /** - * Public interface to build and show a notification in the notification bar - * @param context passed context - * @param notificationTitle title of the notification - * @param notificationMessage message to be displayed in the notification - * @param notificationId the notificationID - * @param intent the intent to be fired when the notification is clicked - */ - public void showNotification( - final Context context, - final String notificationTitle, - final String notificationMessage, - final int notificationId, - final Intent intent - ) { - notificationBuilder.setDefaults(DEFAULT_ALL) - .setContentTitle(notificationTitle) - .setStyle(new NotificationCompat.BigTextStyle() - .bigText(notificationMessage)) - .setSmallIcon(R.drawable.ic_launcher) - .setProgress(0, 0, false) - .setOngoing(false) - .setPriority(PRIORITY_HIGH); - - int flags = PendingIntent.FLAG_UPDATE_CURRENT; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - flags |= PendingIntent.FLAG_IMMUTABLE; // This flag was introduced in API 23 - } - - final PendingIntent pendingIntent = PendingIntent.getActivity(context, 1, intent, flags); - notificationBuilder.setContentIntent(pendingIntent); - notificationManager.notify(notificationId, notificationBuilder.build()); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationHelper.kt b/app/src/main/java/fr/free/nrw/commons/notification/NotificationHelper.kt new file mode 100644 index 000000000..101a8fccc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationHelper.kt @@ -0,0 +1,73 @@ +package fr.free.nrw.commons.notification + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import javax.inject.Inject +import javax.inject.Singleton +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.R +import androidx.core.app.NotificationCompat.DEFAULT_ALL +import androidx.core.app.NotificationCompat.PRIORITY_HIGH + +/** + * Helper class that can be used to build a generic notification + * Going forward all notifications should be built using this helper class + */ +@Singleton +class NotificationHelper @Inject constructor( + context: Context +) { + + companion object { + const val NOTIFICATION_DELETE = 1 + const val NOTIFICATION_EDIT_CATEGORY = 2 + const val NOTIFICATION_EDIT_COORDINATES = 3 + const val NOTIFICATION_EDIT_DESCRIPTION = 4 + const val NOTIFICATION_EDIT_DEPICTIONS = 5 + } + + private val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private val notificationBuilder: NotificationCompat.Builder = NotificationCompat + .Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL) + .setOnlyAlertOnce(true) + + /** + * Public interface to build and show a notification in the notification bar + * @param context passed context + * @param notificationTitle title of the notification + * @param notificationMessage message to be displayed in the notification + * @param notificationId the notificationID + * @param intent the intent to be fired when the notification is clicked + */ + fun showNotification( + context: Context, + notificationTitle: String, + notificationMessage: String, + notificationId: Int, + intent: Intent + ) { + notificationBuilder.setDefaults(NotificationCompat.DEFAULT_ALL) + .setContentTitle(notificationTitle) + .setStyle(NotificationCompat.BigTextStyle().bigText(notificationMessage)) + .setSmallIcon(R.drawable.ic_launcher) + .setProgress(0, 0, false) + .setOngoing(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + + val pendingIntent = PendingIntent.getActivity(context, 1, intent, flags) + notificationBuilder.setContentIntent(pendingIntent) + notificationManager.notify(notificationId, notificationBuilder.build()) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationWorkerFragment.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationWorkerFragment.java deleted file mode 100644 index ffee5eac2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationWorkerFragment.java +++ /dev/null @@ -1,31 +0,0 @@ -package fr.free.nrw.commons.notification; - -import android.app.Fragment; -import android.os.Bundle; - -import androidx.annotation.Nullable; - -import fr.free.nrw.commons.notification.models.Notification; -import java.util.List; - -/** - * Created by knightshade on 25/2/18. - */ - -public class NotificationWorkerFragment extends Fragment { - private List notificationList; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setRetainInstance(true); - } - - public void setNotificationList(List notificationList){ - this.notificationList = notificationList; - } - - public List getNotificationList(){ - return notificationList; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationWorkerFragment.kt b/app/src/main/java/fr/free/nrw/commons/notification/NotificationWorkerFragment.kt new file mode 100644 index 000000000..928651b9a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationWorkerFragment.kt @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.notification + +import android.app.Fragment +import android.os.Bundle + +import fr.free.nrw.commons.notification.models.Notification + + +/** + * Created by knightshade on 25/2/18. + */ +class NotificationWorkerFragment : Fragment() { + + var notificationList: List? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + retainInstance = true + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/models/NotificationType.java b/app/src/main/java/fr/free/nrw/commons/notification/models/NotificationType.java deleted file mode 100644 index fb9ae7e99..000000000 --- a/app/src/main/java/fr/free/nrw/commons/notification/models/NotificationType.java +++ /dev/null @@ -1,28 +0,0 @@ -package fr.free.nrw.commons.notification.models; - -public enum NotificationType { - THANK_YOU_EDIT("thank-you-edit"), - EDIT_USER_TALK("edit-user-talk"), - MENTION("mention"), - EMAIL("email"), - WELCOME("welcome"), - UNKNOWN("unknown"); - private String type; - - NotificationType(String type) { - this.type = type; - } - - public String getType() { - return type; - } - - public static NotificationType handledValueOf(String name) { - for (NotificationType e : values()) { - if (e.getType().equals(name)) { - return e; - } - } - return UNKNOWN; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/models/NotificationType.kt b/app/src/main/java/fr/free/nrw/commons/notification/models/NotificationType.kt new file mode 100644 index 000000000..9034b3c59 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/models/NotificationType.kt @@ -0,0 +1,33 @@ +package fr.free.nrw.commons.notification.models + +enum class NotificationType(private val type: String) { + THANK_YOU_EDIT("thank-you-edit"), + + EDIT_USER_TALK("edit-user-talk"), + + MENTION("mention"), + + EMAIL("email"), + + WELCOME("welcome"), + + UNKNOWN("unknown"); + + // Getter for the type property + fun getType(): String { + return type + } + + companion object { + // Returns the corresponding NotificationType for a given name or UNKNOWN + // if no match is found + fun handledValueOf(name: String): NotificationType { + for (e in values()) { + if (e.type == name) { + return e + } + } + return UNKNOWN + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java b/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java index 9acf5b595..4a08760af 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java @@ -16,7 +16,6 @@ import androidx.annotation.NonNull; import androidx.core.content.FileProvider; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; -import com.google.android.material.tabs.TabLayout; import fr.free.nrw.commons.R; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.ViewPagerAdapter; @@ -32,6 +31,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import javax.inject.Inject; /** @@ -139,14 +139,14 @@ public class ProfileActivity extends BaseActivity { leaderboardFragment.setArguments(leaderBoardBundle); fragmentList.add(leaderboardFragment); - titleList.add(getResources().getString(R.string.leaderboard_tab_title).toUpperCase()); + titleList.add(getResources().getString(R.string.leaderboard_tab_title).toUpperCase(Locale.ROOT)); contributionsFragment = new ContributionsFragment(); Bundle contributionsListBundle = new Bundle(); contributionsListBundle.putString(KEY_USERNAME, userName); contributionsFragment.setArguments(contributionsListBundle); fragmentList.add(contributionsFragment); - titleList.add(getString(R.string.contributions_fragment).toUpperCase()); + titleList.add(getString(R.string.contributions_fragment).toUpperCase(Locale.ROOT)); viewPagerAdapter.setTabData(fragmentList, titleList); viewPagerAdapter.notifyDataSetChanged(); @@ -156,7 +156,7 @@ public class ProfileActivity extends BaseActivity { @Override public void onDestroy() { super.onDestroy(); - compositeDisposable.clear(); + getCompositeDisposable().clear(); } /** @@ -206,8 +206,8 @@ public class ProfileActivity extends BaseActivity { getString(R.string.cancel), () -> shareScreen(screenshot), () -> {}, - view, - true); + view + ); } /** diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/Achievements.kt b/app/src/main/java/fr/free/nrw/commons/profile/achievements/Achievements.kt index 861040fcf..7b23db2cd 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/achievements/Achievements.kt +++ b/app/src/main/java/fr/free/nrw/commons/profile/achievements/Achievements.kt @@ -1,104 +1,45 @@ package fr.free.nrw.commons.profile.achievements /** - * Represents Achievements class and stores all the parameters + * Represents Achievements data class and stores all the parameters. + * Immutable version with default values for optional properties. */ -class Achievements { +data class Achievements( + val uniqueUsedImages: Int = 0, + val articlesUsingImages: Int = 0, + val thanksReceived: Int = 0, + val featuredImages: Int = 0, + val qualityImages: Int = 0, + val imagesUploaded: Int = 0, + val revertCount: Int = 0 +) { /** - * The count of unique images used by the wiki. - * @return The count of unique images used. - * @param uniqueUsedImages The count to set for unique images used. - */ - var uniqueUsedImages = 0 - private var articlesUsingImages = 0 - - /** - * The count of thanks received. - * @return The count of thanks received. - * @param thanksReceived The count to set for thanks received. - */ - var thanksReceived = 0 - - /** - * The count of featured images. - * @return The count of featured images. - * @param featuredImages The count to set for featured images. - */ - var featuredImages = 0 - - /** - * The count of quality images. - * @return The count of quality images. - * @param qualityImages The count to set for quality images. - */ - var qualityImages = 0 - - /** - * The count of images uploaded. - * @return The count of images uploaded. - * @param imagesUploaded The count to set for images uploaded. - */ - var imagesUploaded = 0 - private var revertCount = 0 - - constructor() {} - - /** - * constructor for achievements class to set its data members - * @param uniqueUsedImages - * @param articlesUsingImages - * @param thanksReceived - * @param featuredImages - * @param imagesUploaded - * @param revertCount - */ - constructor( - uniqueUsedImages: Int, - articlesUsingImages: Int, - thanksReceived: Int, - featuredImages: Int, - qualityImages: Int, - imagesUploaded: Int, - revertCount: Int, - ) { - this.uniqueUsedImages = uniqueUsedImages - this.articlesUsingImages = articlesUsingImages - this.thanksReceived = thanksReceived - this.featuredImages = featuredImages - this.qualityImages = qualityImages - this.imagesUploaded = imagesUploaded - this.revertCount = revertCount - } - - /** - * used to calculate the percentages of images that haven't been reverted - * @return + * Used to calculate the percentages of images that haven't been reverted. + * Returns 100 if imagesUploaded is 0 to avoid division by zero. */ val notRevertPercentage: Int - get() = - try { - (imagesUploaded - revertCount) * 100 / imagesUploaded - } catch (divideByZero: ArithmeticException) { - 100 - } + get() = if (imagesUploaded > 0) { + (imagesUploaded - revertCount) * 100 / imagesUploaded + } else { + 100 + } companion object { /** - * Get Achievements object from FeedbackResponse + * Get Achievements object from FeedbackResponse. * - * @param response - * @return + * @param response The feedback response to convert. + * @return An Achievements object with values from the response. */ @JvmStatic - fun from(response: FeedbackResponse): Achievements = - Achievements( - response.uniqueUsedImages, - response.articlesUsingImages, - response.thanksReceived, - response.featuredImages.featuredPicturesOnWikimediaCommons, - response.featuredImages.qualityImages, - 0, - response.deletedUploads, - ) + fun from(response: FeedbackResponse): Achievements = Achievements( + uniqueUsedImages = response.uniqueUsedImages, + articlesUsingImages = response.articlesUsingImages, + thanksReceived = response.thanksReceived, + featuredImages = response.featuredImages.featuredPicturesOnWikimediaCommons, + qualityImages = response.featuredImages.qualityImages, + imagesUploaded = 0, // Assuming imagesUploaded should be 0 + revertCount = response.deletedUploads + ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java deleted file mode 100644 index 46ea631fb..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java +++ /dev/null @@ -1,481 +0,0 @@ -package fr.free.nrw.commons.profile.achievements; - -import android.accounts.Account; -import android.content.Context; -import android.net.Uri; -import android.os.Bundle; -import android.util.DisplayMetrics; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.databinding.FragmentAchievementsBinding; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.kvstore.BasicKvStore; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.profile.ProfileActivity; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import java.util.Objects; -import javax.inject.Inject; -import org.apache.commons.lang3.StringUtils; -import timber.log.Timber; - -/** - * fragment for sharing feedback on uploaded activity - */ -public class AchievementsFragment extends CommonsDaggerSupportFragment { - - private static final double BADGE_IMAGE_WIDTH_RATIO = 0.4; - private static final double BADGE_IMAGE_HEIGHT_RATIO = 0.3; - - /** - * Help link URLs - */ - private static final String IMAGES_UPLOADED_URL = "https://commons.wikimedia.org/wiki/Commons:Project_scope"; - private static final String IMAGES_REVERT_URL = "https://commons.wikimedia.org/wiki/Commons:Deletion_policy#Reasons_for_deletion"; - private static final String IMAGES_USED_URL = "https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style/Images"; - private static final String IMAGES_NEARBY_PLACES_URL = "https://www.wikidata.org/wiki/Property:P18"; - private static final String IMAGES_FEATURED_URL = "https://commons.wikimedia.org/wiki/Commons:Featured_pictures"; - private static final String QUALITY_IMAGE_URL = "https://commons.wikimedia.org/wiki/Commons:Quality_images"; - private static final String THANKS_URL = "https://www.mediawiki.org/wiki/Extension:Thanks"; - - private LevelController.LevelInfo levelInfo; - - @Inject - SessionManager sessionManager; - - @Inject - OkHttpJsonApiClient okHttpJsonApiClient; - - private FragmentAchievementsBinding binding; - - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - - // To keep track of the number of wiki edits made by a user - private int numberOfEdits = 0; - - private String userName; - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (getArguments() != null) { - userName = getArguments().getString(ProfileActivity.KEY_USERNAME); - } - } - - /** - * This method helps in the creation Achievement screen and - * dynamically set the size of imageView - * - * @param savedInstanceState Data bundle - */ - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - binding = FragmentAchievementsBinding.inflate(inflater, container, false); - View rootView = binding.getRoot(); - - binding.achievementInfo.setOnClickListener(view -> showInfoDialog()); - binding.imagesUploadInfo.setOnClickListener(view -> showUploadInfo()); - binding.imagesRevertedInfo.setOnClickListener(view -> showRevertedInfo()); - binding.imagesUsedByWikiInfo.setOnClickListener(view -> showUsedByWikiInfo()); - binding.imagesNearbyInfo.setOnClickListener(view -> showImagesViaNearbyInfo()); - binding.imagesFeaturedInfo.setOnClickListener(view -> showFeaturedImagesInfo()); - binding.thanksReceivedInfo.setOnClickListener(view -> showThanksReceivedInfo()); - binding.qualityImagesInfo.setOnClickListener(view -> showQualityImagesInfo()); - - // DisplayMetrics used to fetch the size of the screen - DisplayMetrics displayMetrics = new DisplayMetrics(); - getActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - int height = displayMetrics.heightPixels; - int width = displayMetrics.widthPixels; - - // Used for the setting the size of imageView at runtime - ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) - binding.achievementBadgeImage.getLayoutParams(); - params.height = (int) (height * BADGE_IMAGE_HEIGHT_RATIO); - params.width = (int) (width * BADGE_IMAGE_WIDTH_RATIO); - binding.achievementBadgeImage.requestLayout(); - binding.progressBar.setVisibility(View.VISIBLE); - - setHasOptionsMenu(true); - - // Set the initial value of WikiData edits to 0 - binding.wikidataEdits.setText("0"); - if(sessionManager.getUserName() == null || sessionManager.getUserName().equals(userName)){ - binding.tvAchievementsOfUser.setVisibility(View.GONE); - }else{ - binding.tvAchievementsOfUser.setVisibility(View.VISIBLE); - binding.tvAchievementsOfUser.setText(getString(R.string.achievements_of_user,userName)); - } - - // Achievements currently unimplemented in Beta flavor. Skip all API calls. - if(ConfigUtils.isBetaFlavour()) { - binding.progressBar.setVisibility(View.GONE); - binding.imagesUsedByWikiText.setText(R.string.no_image); - binding.imagesRevertedText.setText(R.string.no_image_reverted); - binding.imagesUploadTextParam.setText(R.string.no_image_uploaded); - binding.wikidataEdits.setText("0"); - binding.imageFeatured.setText("0"); - binding.qualityImages.setText("0"); - binding.achievementLevel.setText("0"); - setMenuVisibility(true); - return rootView; - } - setWikidataEditCount(); - setAchievements(); - return rootView; - } - - @Override - public void onDestroyView() { - binding = null; - super.onDestroyView(); - } - - @Override - public void setMenuVisibility(boolean visible) { - super.setMenuVisibility(visible); - - // Whenever this fragment is revealed in a menu, - // notify Beta users the page data is unavailable - if(ConfigUtils.isBetaFlavour() && visible) { - Context ctx = null; - if(getContext() != null) { - ctx = getContext(); - } else if(getView() != null && getView().getContext() != null) { - ctx = getView().getContext(); - } - if(ctx != null) { - Toast.makeText(ctx, - R.string.achievements_unavailable_beta, - Toast.LENGTH_LONG).show(); - } - } - } - - /** - * To invoke the AlertDialog on clicking info button - */ - protected void showInfoDialog(){ - launchAlert( - getResources().getString(R.string.Achievements), - getResources().getString(R.string.achievements_info_message)); - } - - /** - * To call the API to get results in form Single - * which then calls parseJson when results are fetched - */ - private void setAchievements() { - binding.progressBar.setVisibility(View.VISIBLE); - if (checkAccount()) { - try{ - - compositeDisposable.add(okHttpJsonApiClient - .getAchievements(Objects.requireNonNull(userName)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - response -> { - if (response != null) { - setUploadCount(Achievements.from(response)); - } else { - Timber.d("success"); - binding.layoutImageReverts.setVisibility(View.INVISIBLE); - binding.achievementBadgeImage.setVisibility(View.INVISIBLE); - // If the number of edits made by the user are more than 150,000 - // in some cases such high number of wiki edit counts cause the - // achievements calculator to fail in some cases, for more details - // refer Issue: #3295 - if (numberOfEdits <= 150000) { - showSnackBarWithRetry(false); - } else { - showSnackBarWithRetry(true); - } - } - }, - t -> { - Timber.e(t, "Fetching achievements statistics failed"); - if (numberOfEdits <= 150000) { - showSnackBarWithRetry(false); - } else { - showSnackBarWithRetry(true); - } - } - )); - } - catch (Exception e){ - Timber.d(e+"success"); - } - } - } - - /** - * To call the API to fetch the count of wiki data edits - * in the form of JavaRx Single object - */ - private void setWikidataEditCount() { - if (StringUtils.isBlank(userName)) { - return; - } - compositeDisposable.add(okHttpJsonApiClient - .getWikidataEdits(userName) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(edits -> { - numberOfEdits = edits; - binding.wikidataEdits.setText(String.valueOf(edits)); - }, e -> { - Timber.e("Error:" + e); - })); - } - - /** - * Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the - * listener passed - * @param tooManyAchievements if this value is true it means that the number of achievements of the - * user are so high that it wrecks havoc with the Achievements calculator due to which request may time - * out. Well this is the Ultimate Achievement - */ - private void showSnackBarWithRetry(boolean tooManyAchievements) { - if (tooManyAchievements) { - binding.progressBar.setVisibility(View.GONE); - ViewUtil.showDismissibleSnackBar(getActivity().findViewById(android.R.id.content), - R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry, view -> setAchievements()); - } else { - binding.progressBar.setVisibility(View.GONE); - ViewUtil.showDismissibleSnackBar(getActivity().findViewById(android.R.id.content), - R.string.achievements_fetch_failed, R.string.retry, view -> setAchievements()); - } - } - - /** - * Shows a generic error toast when error occurs while loading achievements or uploads - */ - private void onError() { - ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.error_occurred)); - binding.progressBar.setVisibility(View.GONE); - } - - /** - * used to the count of images uploaded by user - */ - private void setUploadCount(Achievements achievements) { - if (checkAccount()) { - compositeDisposable.add(okHttpJsonApiClient - .getUploadCount(Objects.requireNonNull(userName)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - uploadCount -> setAchievementsUploadCount(achievements, uploadCount), - t -> { - Timber.e(t, "Fetching upload count failed"); - onError(); - } - )); - } - } - - /** - * used to set achievements upload count and call hideProgressbar - * @param uploadCount - */ - private void setAchievementsUploadCount(Achievements achievements, int uploadCount) { - achievements.setImagesUploaded(uploadCount); - hideProgressBar(achievements); - } - - /** - * used to the uploaded images progressbar - * @param uploadCount - */ - private void setUploadProgress(int uploadCount){ - if (uploadCount==0){ - setZeroAchievements(); - }else { - binding.imagesUploadedProgressbar.setVisibility(View.VISIBLE); - binding.imagesUploadedProgressbar.setProgress - (100*uploadCount/levelInfo.getMaxUploadCount()); - binding.tvUploadedImages.setText - (uploadCount + "/" + levelInfo.getMaxUploadCount()); - } - - } - - private void setZeroAchievements() { - String message = !Objects.equals(sessionManager.getUserName(), userName) ? - getString(R.string.no_achievements_yet, userName) : - getString(R.string.you_have_no_achievements_yet); - DialogUtil.showAlertDialog(getActivity(), - null, - message, - getString(R.string.ok), - () -> {}, - true); -// binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE); -// binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE); -// binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE); - binding.achievementBadgeImage.setVisibility(View.INVISIBLE); - binding.imagesUsedByWikiText.setText(R.string.no_image); - binding.imagesRevertedText.setText(R.string.no_image_reverted); - binding.imagesUploadTextParam.setText(R.string.no_image_uploaded); - binding.achievementBadgeImage.setVisibility(View.INVISIBLE); - } - - /** - * used to set the non revert image percentage - * @param notRevertPercentage - */ - private void setImageRevertPercentage(int notRevertPercentage){ - binding.imageRevertsProgressbar.setVisibility(View.VISIBLE); - binding.imageRevertsProgressbar.setProgress(notRevertPercentage); - final String revertPercentage = Integer.toString(notRevertPercentage); - binding.tvRevertedImages.setText(revertPercentage + "%"); - binding.imagesRevertLimitText.setText(getResources().getString(R.string.achievements_revert_limit_message)+ levelInfo.getMinNonRevertPercentage() + "%"); - } - - /** - * Used the inflate the fetched statistics of the images uploaded by user - * and assign badge and level. Also stores the achievements level of the user in BasicKvStore to display in menu - * @param achievements - */ - private void inflateAchievements(Achievements achievements) { -// binding.imagesUsedByWikiProgressBar.setVisibility(View.VISIBLE); - binding.thanksReceived.setText(String.valueOf(achievements.getThanksReceived())); - binding.imagesUsedByWikiProgressBar.setProgress - (100 * achievements.getUniqueUsedImages() / levelInfo.getMaxUniqueImages()); - binding.tvWikiPb.setText(achievements.getUniqueUsedImages() + "/" - + levelInfo.getMaxUniqueImages()); - binding.imageFeatured.setText(String.valueOf(achievements.getFeaturedImages())); - binding.qualityImages.setText(String.valueOf(achievements.getQualityImages())); - String levelUpInfoString = getString(R.string.level).toUpperCase(); - levelUpInfoString += " " + levelInfo.getLevelNumber(); - binding.achievementLevel.setText(levelUpInfoString); - binding.achievementBadgeImage.setImageDrawable(VectorDrawableCompat.create(getResources(), R.drawable.badge, - new ContextThemeWrapper(getActivity(), levelInfo.getLevelStyle()).getTheme())); - binding.achievementBadgeText.setText(Integer.toString(levelInfo.getLevelNumber())); - BasicKvStore store = new BasicKvStore(this.getContext(), userName); - store.putString("userAchievementsLevel", Integer.toString(levelInfo.getLevelNumber())); - } - - /** - * to hide progressbar - */ - private void hideProgressBar(Achievements achievements) { - if (binding.progressBar != null) { - levelInfo = LevelController.LevelInfo.from(achievements.getImagesUploaded(), - achievements.getUniqueUsedImages(), - achievements.getNotRevertPercentage()); - inflateAchievements(achievements); - setUploadProgress(achievements.getImagesUploaded()); - setImageRevertPercentage(achievements.getNotRevertPercentage()); - binding.progressBar.setVisibility(View.GONE); - } - } - - protected void showUploadInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.images_uploaded), - getResources().getString(R.string.images_uploaded_explanation), - IMAGES_UPLOADED_URL); - } - - protected void showRevertedInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.image_reverts), - getResources().getString(R.string.images_reverted_explanation), - IMAGES_REVERT_URL); - } - - protected void showUsedByWikiInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.images_used_by_wiki), - getResources().getString(R.string.images_used_explanation), - IMAGES_USED_URL); - } - - protected void showImagesViaNearbyInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.statistics_wikidata_edits), - getResources().getString(R.string.images_via_nearby_explanation), - IMAGES_NEARBY_PLACES_URL); - } - - protected void showFeaturedImagesInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.statistics_featured), - getResources().getString(R.string.images_featured_explanation), - IMAGES_FEATURED_URL); - } - - protected void showThanksReceivedInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.statistics_thanks), - getResources().getString(R.string.thanks_received_explanation), - THANKS_URL); - } - - public void showQualityImagesInfo() { - launchAlertWithHelpLink( - getResources().getString(R.string.statistics_quality), - getResources().getString(R.string.quality_images_info), - QUALITY_IMAGE_URL); - } - - /** - * takes title and message as input to display alerts - * @param title - * @param message - */ - private void launchAlert(String title, String message){ - DialogUtil.showAlertDialog(getActivity(), - title, - message, - getString(R.string.ok), - () -> {}, - true); - } - - /** - * Launch Alert with a READ MORE button and clicking it open a custom webpage - */ - private void launchAlertWithHelpLink(String title, String message, String helpLinkUrl) { - DialogUtil.showAlertDialog(getActivity(), - title, - message, - getString(R.string.ok), - getString(R.string.read_help_link), - () -> {}, - () -> Utils.handleWebUrl(requireContext(), Uri.parse(helpLinkUrl)), - null, - true); - } - - /** - * check to ensure that user is logged in - * @return - */ - private boolean checkAccount(){ - Account currentAccount = sessionManager.getCurrentAccount(); - if (currentAccount == null) { - Timber.d("Current account is null"); - ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.user_not_logged_in)); - sessionManager.forceLogin(getActivity()); - return false; - } - return true; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt new file mode 100644 index 000000000..af07423eb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt @@ -0,0 +1,563 @@ +package fr.free.nrw.commons.profile.achievements + +import android.net.Uri +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.Toast +import androidx.appcompat.view.ContextThemeWrapper +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat +import com.google.android.material.badge.BadgeDrawable +import com.google.android.material.badge.BadgeUtils +import com.google.android.material.badge.ExperimentalBadgeUtils +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.databinding.FragmentAchievementsBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.kvstore.BasicKvStore +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.profile.achievements.LevelController.LevelInfo.Companion.from +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.ViewUtil.showDismissibleSnackBar +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.apache.commons.lang3.StringUtils +import timber.log.Timber +import java.util.Objects +import javax.inject.Inject + +class AchievementsFragment : CommonsDaggerSupportFragment(){ + private lateinit var levelInfo: LevelController.LevelInfo + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var okHttpJsonApiClient: OkHttpJsonApiClient + + private var _binding: FragmentAchievementsBinding? = null + private val binding get() = _binding!! + // To keep track of the number of wiki edits made by a user + private var numberOfEdits: Int = 0 + + private var userName: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + userName = it.getString(ProfileActivity.KEY_USERNAME) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAchievementsBinding.inflate(inflater, container, false) + + binding.achievementInfo.setOnClickListener { showInfoDialog() } + binding.imagesUploadInfoIcon.setOnClickListener { showUploadInfo() } + binding.imagesRevertedInfoIcon.setOnClickListener { showRevertedInfo() } + binding.imagesUsedByWikiInfoIcon.setOnClickListener { showUsedByWikiInfo() } + binding.wikidataEditsIcon.setOnClickListener { showImagesViaNearbyInfo() } + binding.featuredImageIcon.setOnClickListener { showFeaturedImagesInfo() } + binding.thanksImageIcon.setOnClickListener { showThanksReceivedInfo() } + binding.qualityImageIcon.setOnClickListener { showQualityImagesInfo() } + + // DisplayMetrics used to fetch the size of the screen + val displayMetrics = DisplayMetrics() + requireActivity().windowManager.defaultDisplay.getMetrics(displayMetrics) + val height = displayMetrics.heightPixels + val width = displayMetrics.widthPixels + + // Used for the setting the size of imageView at runtime + // TODO REMOVE + val params = binding.achievementBadgeImage.layoutParams as ConstraintLayout.LayoutParams + params.height = (height * BADGE_IMAGE_HEIGHT_RATIO).toInt() + params.width = (width * BADGE_IMAGE_WIDTH_RATIO).toInt() + binding.achievementBadgeImage.requestLayout() + binding.progressBar.visibility = View.VISIBLE + + setHasOptionsMenu(true) + if (sessionManager.userName == null || sessionManager.userName == userName) { + binding.tvAchievementsOfUser.visibility = View.GONE + } else { + binding.tvAchievementsOfUser.visibility = View.VISIBLE + binding.tvAchievementsOfUser.text = getString(R.string.achievements_of_user, userName) + } + if (isBetaFlavour) { + binding.layout.visibility = View.GONE + setMenuVisibility(true) + return binding.root + } + + + setWikidataEditCount() + setAchievements() + return binding.root + + } + + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + + override fun setMenuVisibility(visible: Boolean) { + super.setMenuVisibility(visible) + + // Whenever this fragment is revealed in a menu, + // notify Beta users the page data is unavailable + if (isBetaFlavour && visible) { + val ctx = context ?: view?.context + ctx?.let { + Toast.makeText(it, R.string.achievements_unavailable_beta, Toast.LENGTH_LONG).show() + } + } + } + + /** + * To invoke the AlertDialog on clicking info button + */ + fun showInfoDialog() { + launchAlert( + resources.getString(R.string.Achievements), + resources.getString(R.string.achievements_info_message) + ) + } + + + + + /** + * To call the API to get results in form Single + * which then calls parseJson when results are fetched + */ + + private fun setAchievements() { + binding.progressBar.visibility = View.VISIBLE + if (checkAccount()) { + try { + compositeDisposable.add( + okHttpJsonApiClient + .getAchievements(userName ?: return) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + if (response != null) { + setUploadCount(Achievements.from(response)) + } else { + Timber.d("Success") + // TODO Create a Method to Hide all the Statistics +// binding.layoutImageReverts.visibility = View.INVISIBLE +// binding.achievementBadgeImage.visibility = View.INVISIBLE + // If the number of edits made by the user are more than 150,000 + // in some cases such high number of wiki edit counts cause the + // achievements calculator to fail in some cases, for more details + // refer Issue: #3295 + if (numberOfEdits <= 150_000) { + showSnackBarWithRetry(false) + } else { + showSnackBarWithRetry(true) + } + } + }, + { throwable -> + Timber.e(throwable, "Fetching achievements statistics failed") + if (numberOfEdits <= 150_000) { + showSnackBarWithRetry(false) + } else { + showSnackBarWithRetry(true) + } + } + ) + ) + } catch (e: Exception) { + Timber.d("Exception: ${e.message}") + } + } + } + + /** + * To call the API to fetch the count of wiki data edits + * in the form of JavaRx Single object + */ + + private fun setWikidataEditCount() { + if (StringUtils.isBlank(userName)) { + return + } + compositeDisposable.add( + okHttpJsonApiClient + .getWikidataEdits(userName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ edits: Int -> + numberOfEdits = edits + showBadgesWithCount(view = binding.wikidataEditsIcon, count = edits) + }, { e: Throwable -> + Timber.e("Error:$e") + }) + ) + } + + /** + * Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the + * listener passed + * @param tooManyAchievements if this value is true it means that the number of achievements of the + * user are so high that it wrecks havoc with the Achievements calculator due to which request may time + * out. Well this is the Ultimate Achievement + */ + private fun showSnackBarWithRetry(tooManyAchievements: Boolean) { + if (tooManyAchievements) { + if (view == null) { + return + } + else { + binding.progressBar.visibility = View.GONE + showDismissibleSnackBar( + requireView().findViewById(android.R.id.content), + R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry + ) { setAchievements() } + } + + } else { + if (view == null) { + return + } + binding.progressBar.visibility = View.GONE + showDismissibleSnackBar( + requireView().findViewById(android.R.id.content), + R.string.achievements_fetch_failed, R.string.retry + ) { setAchievements() } + } + } + + /** + * Shows a generic error toast when error occurs while loading achievements or uploads + */ + private fun onError() { + showLongToast(requireActivity(), resources.getString(R.string.error_occurred)) + binding.progressBar.visibility = View.GONE + } + + /** + * used to the count of images uploaded by user + */ + + private fun setUploadCount(achievements: Achievements) { + if (checkAccount()) { + compositeDisposable.add(okHttpJsonApiClient + .getUploadCount(Objects.requireNonNull(userName)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { uploadCount: Int? -> + setAchievementsUploadCount( + achievements, + uploadCount ?:0 + ) + }, + { t: Throwable? -> + Timber.e(t, "Fetching upload count failed") + onError() + } + )) + } + } + + /** + * used to set achievements upload count and call hideProgressbar + * @param uploadCount + */ + private fun setAchievementsUploadCount(achievements: Achievements, uploadCount: Int) { + // Create a new instance of Achievements with updated imagesUploaded + val updatedAchievements = Achievements( + achievements.uniqueUsedImages, + achievements.articlesUsingImages, + achievements.thanksReceived, + achievements.featuredImages, + achievements.qualityImages, + uploadCount, // Update imagesUploaded with new value + achievements.revertCount + ) + + hideProgressBar(updatedAchievements) + } + + /** + * used to the uploaded images progressbar + * @param uploadCount + */ + private fun setUploadProgress(uploadCount: Int) { + if (uploadCount == 0) { + setZeroAchievements() + } else { + binding.imagesUploadedProgressbar.visibility = View.VISIBLE + binding.imagesUploadedProgressbar.progress = + 100 * uploadCount / levelInfo.maxUploadCount + binding.imageUploadedTVCount.text = uploadCount.toString() + "/" + levelInfo.maxUploadCount + } + } + + private fun setZeroAchievements() { + val message = if (sessionManager.userName != userName) { + getString(R.string.no_achievements_yet, userName ) + } else { + getString(R.string.you_have_no_achievements_yet) + } + showAlertDialog( + requireActivity(), + null, + message, + getString(R.string.ok), + {} + ) + +// binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE); +// binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE); +// binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE); + //binding.achievementBadgeImage.visibility = View.INVISIBLE // TODO + binding.imagesUsedCount.setText(R.string.no_image) + binding.imagesRevertedText.setText(R.string.no_image_reverted) + binding.imagesUploadTextParam.setText(R.string.no_image_uploaded) + } + + /** + * used to set the non revert image percentage + * @param notRevertPercentage + */ + private fun setImageRevertPercentage(notRevertPercentage: Int) { + binding.imageRevertsProgressbar.visibility = View.VISIBLE + binding.imageRevertsProgressbar.progress = notRevertPercentage + val revertPercentage = notRevertPercentage.toString() + binding.imageRevertTVCount.text = "$revertPercentage%" + binding.imagesRevertLimitText.text = + resources.getString(R.string.achievements_revert_limit_message) + levelInfo.minNonRevertPercentage + "%" + } + + /** + * Used the inflate the fetched statistics of the images uploaded by user + * and assign badge and level. Also stores the achievements level of the user in BasicKvStore to display in menu + * @param achievements + */ + private fun inflateAchievements(achievements: Achievements) { + + // Thanks Received Badge + showBadgesWithCount(view = binding.thanksImageIcon, count = achievements.thanksReceived) + + // Featured Images Badge + showBadgesWithCount(view = binding.featuredImageIcon, count = achievements.featuredImages) + + // Quality Images Badge + showBadgesWithCount(view = binding.qualityImageIcon, count = achievements.qualityImages) + + binding.imagesUsedByWikiProgressBar.progress = + 100 * achievements.uniqueUsedImages / levelInfo.maxUniqueImages + binding.imagesUsedCount.text = (achievements.uniqueUsedImages.toString() + "/" + + levelInfo.maxUniqueImages) + + binding.achievementLevel.text = getString(R.string.level,levelInfo.levelNumber) + binding.achievementBadgeImage.setImageDrawable( + VectorDrawableCompat.create( + resources, R.drawable.badge, + ContextThemeWrapper(activity, levelInfo.levelStyle).theme + ) + ) + binding.achievementBadgeText.text = levelInfo.levelNumber.toString() + val store = BasicKvStore(requireContext(), userName) + store.putString("userAchievementsLevel", levelInfo.levelNumber.toString()) + } + + /** + * This function is used to show badge on any view (button, imageView, etc) + * @param view The View on which the badge will be displayed eg (button, imageView, etc) + * @param count The number to be displayed inside the badge. + * @param backgroundColor The badge background color. Default is R.attr.colorPrimary + * @param badgeTextColor The badge text color. Default is R.attr.colorPrimary + * @param badgeGravity The position of the badge [TOP_END,TOP_START,BOTTOM_END,BOTTOM_START]. Default is TOP_END + * @return if the number is 0, then it will not create badge for it and hide the view + * @see https://developer.android.com/reference/com/google/android/material/badge/BadgeDrawable + */ + + private fun showBadgesWithCount( + view: View, + count: Int, + backgroundColor: Int = R.attr.colorPrimary, + badgeTextColor: Int = R.attr.textEnabled, + badgeGravity: Int = BadgeDrawable.TOP_END + ) { + //https://stackoverflow.com/a/67742035 + if (count == 0) { + view.visibility = View.GONE + return + } + + view.viewTreeObserver.addOnGlobalLayoutListener(object : + ViewTreeObserver.OnGlobalLayoutListener { + /** + * Callback method to be invoked when the global layout state or the visibility of views + * within the view tree changes + */ + @ExperimentalBadgeUtils + override fun onGlobalLayout() { + view.visibility = View.VISIBLE + val badgeDrawable = BadgeDrawable.create(requireActivity()) + badgeDrawable.number = count + badgeDrawable.badgeGravity = badgeGravity + badgeDrawable.badgeTextColor = badgeTextColor + badgeDrawable.backgroundColor = backgroundColor + BadgeUtils.attachBadgeDrawable(badgeDrawable, view) + view.getViewTreeObserver().removeOnGlobalLayoutListener(this) + } + }) + } + + /** + * to hide progressbar + */ + private fun hideProgressBar(achievements: Achievements) { + if (binding.progressBar != null) { + levelInfo = from( + achievements.imagesUploaded, + achievements.uniqueUsedImages, + achievements.notRevertPercentage + ) + inflateAchievements(achievements) + setUploadProgress(achievements.imagesUploaded) + setImageRevertPercentage(achievements.notRevertPercentage) + binding.progressBar.visibility = View.GONE + } + } + + fun showUploadInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.images_uploaded), + resources.getString(R.string.images_uploaded_explanation), + IMAGES_UPLOADED_URL + ) + } + + fun showRevertedInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.image_reverts), + resources.getString(R.string.images_reverted_explanation), + IMAGES_REVERT_URL + ) + } + + fun showUsedByWikiInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.images_used_by_wiki), + resources.getString(R.string.images_used_explanation), + IMAGES_USED_URL + ) + } + + fun showImagesViaNearbyInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.statistics_wikidata_edits), + resources.getString(R.string.images_via_nearby_explanation), + IMAGES_NEARBY_PLACES_URL + ) + } + + fun showFeaturedImagesInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.statistics_featured), + resources.getString(R.string.images_featured_explanation), + IMAGES_FEATURED_URL + ) + } + + fun showThanksReceivedInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.statistics_thanks), + resources.getString(R.string.thanks_received_explanation), + THANKS_URL + ) + } + + fun showQualityImagesInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.statistics_quality), + resources.getString(R.string.quality_images_info), + QUALITY_IMAGE_URL + ) + } + + /** + * takes title and message as input to display alerts + * @param title + * @param message + */ + private fun launchAlert(title: String, message: String) { + showAlertDialog( + requireActivity(), + title, + message, + getString(R.string.ok), + {} + ) + } + + /** + * Launch Alert with a READ MORE button and clicking it open a custom webpage + */ + private fun launchAlertWithHelpLink(title: String, message: String, helpLinkUrl: String) { + showAlertDialog( + requireActivity(), + title, + message, + getString(R.string.ok), + getString(R.string.read_help_link), + {}, + { Utils.handleWebUrl(requireContext(), Uri.parse(helpLinkUrl)) }, + null + ) + } + /** + * check to ensure that user is logged in + * @return + */ + private fun checkAccount(): Boolean { + val currentAccount = sessionManager.currentAccount + if (currentAccount == null) { + Timber.d("Current account is null") + showLongToast(requireActivity(), resources.getString(R.string.user_not_logged_in)) + sessionManager.forceLogin(activity) + return false + } + return true + } + + + + companion object{ + private const val BADGE_IMAGE_WIDTH_RATIO = 0.4 + private const val BADGE_IMAGE_HEIGHT_RATIO = 0.3 + + /** + * Help link URLs + */ + private const val IMAGES_UPLOADED_URL = "https://commons.wikimedia.org/wiki/Commons:Project_scope" + private const val IMAGES_REVERT_URL = "https://commons.wikimedia.org/wiki/Commons:Deletion_policy#Reasons_for_deletion" + private const val IMAGES_USED_URL = "https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style/Images" + private const val IMAGES_NEARBY_PLACES_URL = "https://www.wikidata.org/wiki/Property:P18" + private const val IMAGES_FEATURED_URL = "https://commons.wikimedia.org/wiki/Commons:Featured_pictures" + private const val QUALITY_IMAGE_URL = "https://commons.wikimedia.org/wiki/Commons:Quality_images" + private const val THANKS_URL = "https://www.mediawiki.org/wiki/Extension:Thanks" + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/FeaturedImages.kt b/app/src/main/java/fr/free/nrw/commons/profile/achievements/FeaturedImages.kt index 4784103fd..2a336d349 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/achievements/FeaturedImages.kt +++ b/app/src/main/java/fr/free/nrw/commons/profile/achievements/FeaturedImages.kt @@ -6,7 +6,7 @@ import com.google.gson.annotations.SerializedName * Represents Featured Images on WikiMedia Commons platform * Used by Achievements and FeedbackResponse (objects) of the user */ -class FeaturedImages( +data class FeaturedImages( @field:SerializedName("Quality_images") val qualityImages: Int, @field:SerializedName("Featured_pictures_on_Wikimedia_Commons") val featuredPicturesOnWikimediaCommons: Int, ) diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.java deleted file mode 100644 index 409450d60..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.java +++ /dev/null @@ -1,125 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LOADED; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LOADING; - -import androidx.annotation.NonNull; -import androidx.lifecycle.MutableLiveData; -import androidx.paging.PageKeyedDataSource; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import io.reactivex.disposables.CompositeDisposable; -import java.util.Objects; -import timber.log.Timber; - -/** - * This class will call the leaderboard API to get new list when the pagination is performed - */ -public class DataSourceClass extends PageKeyedDataSource { - - private OkHttpJsonApiClient okHttpJsonApiClient; - private SessionManager sessionManager; - private MutableLiveData progressLiveStatus; - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - private String duration; - private String category; - private int limit; - private int offset; - - /** - * Initialise the Data Source Class with API params - * @param okHttpJsonApiClient - * @param sessionManager - * @param duration - * @param category - * @param limit - * @param offset - */ - public DataSourceClass(OkHttpJsonApiClient okHttpJsonApiClient,SessionManager sessionManager, - String duration, String category, int limit, int offset) { - this.okHttpJsonApiClient = okHttpJsonApiClient; - this.sessionManager = sessionManager; - this.duration = duration; - this.category = category; - this.limit = limit; - this.offset = offset; - progressLiveStatus = new MutableLiveData<>(); - } - - - /** - * @return the status of the list - */ - public MutableLiveData getProgressLiveStatus() { - return progressLiveStatus; - } - - /** - * Loads the initial set of data from API - * @param params - * @param callback - */ - @Override - public void loadInitial(@NonNull LoadInitialParams params, - @NonNull LoadInitialCallback callback) { - - compositeDisposable.add(okHttpJsonApiClient - .getLeaderboard(Objects.requireNonNull(sessionManager.getCurrentAccount()).name, - duration, category, String.valueOf(limit), String.valueOf(offset)) - .doOnSubscribe(disposable -> { - compositeDisposable.add(disposable); - progressLiveStatus.postValue(LOADING); - }).subscribe( - response -> { - if (response != null && response.getStatus() == 200) { - progressLiveStatus.postValue(LOADED); - callback.onResult(response.getLeaderboardList(), null, response.getLimit()); - } - }, - t -> { - Timber.e(t, "Fetching leaderboard statistics failed"); - progressLiveStatus.postValue(LOADING); - } - )); - - } - - /** - * Loads any data before the inital page is loaded - * @param params - * @param callback - */ - @Override - public void loadBefore(@NonNull LoadParams params, - @NonNull LoadCallback callback) { - - } - - /** - * Loads the next set of data on scrolling with offset as the limit of the last set of data - * @param params - * @param callback - */ - @Override - public void loadAfter(@NonNull LoadParams params, - @NonNull LoadCallback callback) { - compositeDisposable.add(okHttpJsonApiClient - .getLeaderboard(Objects.requireNonNull(sessionManager.getCurrentAccount()).name, - duration, category, String.valueOf(limit), String.valueOf(params.key)) - .doOnSubscribe(disposable -> { - compositeDisposable.add(disposable); - progressLiveStatus.postValue(LOADING); - }).subscribe( - response -> { - if (response != null && response.getStatus() == 200) { - progressLiveStatus.postValue(LOADED); - callback.onResult(response.getLeaderboardList(), params.key + limit); - } - }, - t -> { - Timber.e(t, "Fetching leaderboard statistics failed"); - progressLiveStatus.postValue(LOADING); - } - )); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.kt new file mode 100644 index 000000000..a6fe747e5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.kt @@ -0,0 +1,79 @@ +package fr.free.nrw.commons.profile.leaderboard + +import android.accounts.Account +import androidx.lifecycle.MutableLiveData +import androidx.paging.PageKeyedDataSource +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus.LOADING +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus.LOADED +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import timber.log.Timber +import java.util.Objects + +/** + * This class will call the leaderboard API to get new list when the pagination is performed + */ +class DataSourceClass( + private val okHttpJsonApiClient: OkHttpJsonApiClient, + private val sessionManager: SessionManager, + private val duration: String?, + private val category: String?, + private val limit: Int, + private val offset: Int +) : PageKeyedDataSource() { + val progressLiveStatus: MutableLiveData = MutableLiveData() + private val compositeDisposable = CompositeDisposable() + + + override fun loadInitial( + params: LoadInitialParams, callback: LoadInitialCallback + ) { + compositeDisposable.add(okHttpJsonApiClient.getLeaderboard( + sessionManager.currentAccount?.name, + duration, + category, + limit.toString(), + offset.toString() + ).doOnSubscribe { disposable: Disposable? -> + compositeDisposable.add(disposable!!) + progressLiveStatus.postValue(LOADING) + }.subscribe({ response: LeaderboardResponse? -> + if (response != null && response.status == 200) { + progressLiveStatus.postValue(LOADED) + callback.onResult(response.leaderboardList!!, null, response.limit) + } + }, { t: Throwable? -> + Timber.e(t, "Fetching leaderboard statistics failed") + progressLiveStatus.postValue(LOADING) + })) + } + + override fun loadBefore( + params: LoadParams, callback: LoadCallback + ) = Unit + + override fun loadAfter( + params: LoadParams, callback: LoadCallback + ) { + compositeDisposable.add(okHttpJsonApiClient.getLeaderboard( + Objects.requireNonNull(sessionManager.currentAccount).name, + duration, + category, + limit.toString(), + params.key.toString() + ).doOnSubscribe { disposable: Disposable? -> + compositeDisposable.add(disposable!!) + progressLiveStatus.postValue(LOADING) + }.subscribe({ response: LeaderboardResponse? -> + if (response != null && response.status == 200) { + progressLiveStatus.postValue(LOADED) + callback.onResult(response.leaderboardList!!, params.key + limit) + } + }, { t: Throwable? -> + Timber.e(t, "Fetching leaderboard statistics failed") + progressLiveStatus.postValue(LOADING) + })) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.java deleted file mode 100644 index b2965785a..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.java +++ /dev/null @@ -1,110 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import androidx.lifecycle.MutableLiveData; -import androidx.paging.DataSource; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import io.reactivex.disposables.CompositeDisposable; - -/** - * This class will create a new instance of the data source class on pagination - */ -public class DataSourceFactory extends DataSource.Factory { - - private MutableLiveData liveData; - private OkHttpJsonApiClient okHttpJsonApiClient; - private CompositeDisposable compositeDisposable; - private SessionManager sessionManager; - private String duration; - private String category; - private int limit; - private int offset; - - /** - * Gets the current set leaderboard list duration - */ - public String getDuration() { - return duration; - } - - /** - * Sets the current set leaderboard duration with the new duration - */ - public void setDuration(final String duration) { - this.duration = duration; - } - - /** - * Gets the current set leaderboard list category - */ - public String getCategory() { - return category; - } - - /** - * Sets the current set leaderboard category with the new category - */ - public void setCategory(final String category) { - this.category = category; - } - - /** - * Gets the current set leaderboard list limit - */ - public int getLimit() { - return limit; - } - - /** - * Sets the current set leaderboard limit with the new limit - */ - public void setLimit(final int limit) { - this.limit = limit; - } - - /** - * Gets the current set leaderboard list offset - */ - public int getOffset() { - return offset; - } - - /** - * Sets the current set leaderboard offset with the new offset - */ - public void setOffset(final int offset) { - this.offset = offset; - } - - /** - * Constructor for DataSourceFactory class - * @param okHttpJsonApiClient client for OKhttp - * @param compositeDisposable composite disposable - * @param sessionManager sessionManager - */ - public DataSourceFactory(OkHttpJsonApiClient okHttpJsonApiClient, CompositeDisposable compositeDisposable, - SessionManager sessionManager) { - this.okHttpJsonApiClient = okHttpJsonApiClient; - this.compositeDisposable = compositeDisposable; - this.sessionManager = sessionManager; - liveData = new MutableLiveData<>(); - } - - /** - * @return the live data - */ - public MutableLiveData getMutableLiveData() { - return liveData; - } - - /** - * Creates the new instance of data source class - * @return - */ - @Override - public DataSource create() { - DataSourceClass dataSourceClass = new DataSourceClass(okHttpJsonApiClient, sessionManager, duration, category, limit, offset); - liveData.postValue(dataSourceClass); - return dataSourceClass; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.kt new file mode 100644 index 000000000..6e979d8c3 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.kt @@ -0,0 +1,27 @@ +package fr.free.nrw.commons.profile.leaderboard + +import androidx.lifecycle.MutableLiveData +import androidx.paging.DataSource +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient + +/** + * This class will create a new instance of the data source class on pagination + */ +class DataSourceFactory( + private val okHttpJsonApiClient: OkHttpJsonApiClient, + private val sessionManager: SessionManager +) : DataSource.Factory() { + val mutableLiveData: MutableLiveData = MutableLiveData() + var duration: String? = null + var category: String? = null + var limit: Int = 0 + var offset: Int = 0 + + /** + * Creates the new instance of data source class + */ + override fun create(): DataSource = DataSourceClass( + okHttpJsonApiClient, sessionManager, duration, category, limit, offset + ).also { mutableLiveData.postValue(it) } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.java deleted file mode 100644 index 800287f4f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.java +++ /dev/null @@ -1,45 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -/** - * This class contains the constant variables for leaderboard - */ -public class LeaderboardConstants { - - /** - * This is the size of the page i.e. number items to load in a batch when pagination is performed - */ - public static final int PAGE_SIZE = 100; - - /** - * This is the starting offset, we set it to 0 to start loading from rank 1 - */ - public static final int START_OFFSET = 0; - - /** - * This is the prefix of the user's homepage url, appending the username will give us complete url - */ - public static final String USER_LINK_PREFIX = "https://commons.wikimedia.org/wiki/User:"; - - /** - * This is the a constant string for the state loading, when the pages are getting loaded we can - * use this constant to identify if we need to show the progress bar or not - */ - public final static String LOADING = "Loading"; - - /** - * This is the a constant string for the state loaded, when the pages are loaded we can - * use this constant to identify if we need to show the progress bar or not - */ - public final static String LOADED = "Loaded"; - - /** - * This API endpoint is to update the leaderboard avatar - */ - public final static String UPDATE_AVATAR_END_POINT = "/update_avatar.py"; - - /** - * This API endpoint is to get leaderboard data - */ - public final static String LEADERBOARD_END_POINT = "/leaderboard.py"; - -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.kt new file mode 100644 index 000000000..bf8d45c5f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.kt @@ -0,0 +1,44 @@ +package fr.free.nrw.commons.profile.leaderboard + +/** + * This class contains the constant variables for leaderboard + */ +object LeaderboardConstants { + /** + * This is the size of the page i.e. number items to load in a batch when pagination is performed + */ + const val PAGE_SIZE: Int = 100 + + /** + * This is the starting offset, we set it to 0 to start loading from rank 1 + */ + const val START_OFFSET: Int = 0 + + /** + * This is the prefix of the user's homepage url, appending the username will give us complete url + */ + const val USER_LINK_PREFIX: String = "https://commons.wikimedia.org/wiki/User:" + + sealed class LoadingStatus { + /** + * This is the state loading, when the pages are getting loaded we can + * use this constant to identify if we need to show the progress bar or not + */ + data object LOADING: LoadingStatus() + /** + * This is the state loaded, when the pages are loaded we can + * use this constant to identify if we need to show the progress bar or not + */ + data object LOADED: LoadingStatus() + } + + /** + * This API endpoint is to update the leaderboard avatar + */ + const val UPDATE_AVATAR_END_POINT: String = "/update_avatar.py" + + /** + * This API endpoint is to get leaderboard data + */ + const val LEADERBOARD_END_POINT: String = "/leaderboard.py" +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.java deleted file mode 100644 index a9cc222ea..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.java +++ /dev/null @@ -1,363 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LOADED; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LOADING; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.PAGE_SIZE; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.START_OFFSET; - -import android.accounts.Account; -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.AdapterView.OnItemSelectedListener; -import android.widget.ArrayAdapter; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.MergeAdapter; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.databinding.FragmentLeaderboardBinding; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import fr.free.nrw.commons.profile.ProfileActivity; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import java.util.Objects; -import javax.inject.Inject; -import timber.log.Timber; - -/** - * This class extends the CommonsDaggerSupportFragment and creates leaderboard fragment - */ -public class LeaderboardFragment extends CommonsDaggerSupportFragment { - - - @Inject - SessionManager sessionManager; - - @Inject - OkHttpJsonApiClient okHttpJsonApiClient; - - @Inject - ViewModelFactory viewModelFactory; - - /** - * View model for the paged leaderboard list - */ - private LeaderboardListViewModel viewModel; - - /** - * Composite disposable for API call - */ - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - - /** - * Duration of the leaderboard API - */ - private String duration; - - /** - * Category of the Leaderboard API - */ - private String category; - - /** - * Page size of the leaderboard API - */ - private int limit = PAGE_SIZE; - - /** - * offset for the leaderboard API - */ - private int offset = START_OFFSET; - - /** - * Set initial User Rank to 0 - */ - private int userRank; - - /** - * This variable represents if user wants to scroll to his rank or not - */ - private boolean scrollToRank; - - private String userName; - - private FragmentLeaderboardBinding binding; - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (getArguments() != null) { - userName = getArguments().getString(ProfileActivity.KEY_USERNAME); - } - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - binding = FragmentLeaderboardBinding.inflate(inflater, container, false); - - hideLayouts(); - - // Leaderboard currently unimplemented in Beta flavor. Skip all API calls and disable menu - if(ConfigUtils.isBetaFlavour()) { - binding.progressBar.setVisibility(View.GONE); - binding.scroll.setVisibility(View.GONE); - return binding.getRoot(); - } - - binding.progressBar.setVisibility(View.VISIBLE); - setSpinners(); - - /** - * This array is for the duration filter, we have three filters weekly, yearly and all-time - * each filter have a key and value pair, the value represents the param of the API - */ - String[] durationValues = getContext().getResources().getStringArray(R.array.leaderboard_duration_values); - - /** - * This array is for the category filter, we have three filters upload, used and nearby - * each filter have a key and value pair, the value represents the param of the API - */ - String[] categoryValues = getContext().getResources().getStringArray(R.array.leaderboard_category_values); - - duration = durationValues[0]; - category = categoryValues[0]; - - setLeaderboard(duration, category, limit, offset); - - binding.durationSpinner.setOnItemSelectedListener(new OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView adapterView, View view, int i, long l) { - - duration = durationValues[binding.durationSpinner.getSelectedItemPosition()]; - refreshLeaderboard(); - } - - @Override - public void onNothingSelected(AdapterView adapterView) { - } - }); - - binding.categorySpinner.setOnItemSelectedListener(new OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView adapterView, View view, int i, long l) { - category = categoryValues[binding.categorySpinner.getSelectedItemPosition()]; - refreshLeaderboard(); - } - - @Override - public void onNothingSelected(AdapterView adapterView) { - } - }); - - - binding.scroll.setOnClickListener(view -> scrollToUserRank()); - - - return binding.getRoot(); - } - - @Override - public void setMenuVisibility(boolean visible) { - super.setMenuVisibility(visible); - - // Whenever this fragment is revealed in a menu, - // notify Beta users the page data is unavailable - if(ConfigUtils.isBetaFlavour() && visible) { - Context ctx = null; - if(getContext() != null) { - ctx = getContext(); - } else if(getView() != null && getView().getContext() != null) { - ctx = getView().getContext(); - } - if(ctx != null) { - Toast.makeText(ctx, - R.string.leaderboard_unavailable_beta, - Toast.LENGTH_LONG).show(); - } - } - } - - /** - * Refreshes the leaderboard list - */ - private void refreshLeaderboard() { - scrollToRank = false; - if (viewModel != null) { - viewModel.refresh(duration, category, limit, offset); - setLeaderboard(duration, category, limit, offset); - } - } - - /** - * Performs Auto Scroll to the User's Rank - * We use userRank+1 to load one extra user and prevent overlapping of my rank button - * If you are viewing the leaderboard below userRank, it scrolls to the user rank at the top - */ - private void scrollToUserRank() { - - if(userRank==0){ - Toast.makeText(getContext(),R.string.no_achievements_yet,Toast.LENGTH_SHORT).show(); - }else { - if (binding == null) { - return; - } - if (Objects.requireNonNull(binding.leaderboardList.getAdapter()).getItemCount() - > userRank + 1) { - binding.leaderboardList.smoothScrollToPosition(userRank + 1); - } else { - if (viewModel != null) { - viewModel.refresh(duration, category, userRank + 1, 0); - setLeaderboard(duration, category, userRank + 1, 0); - scrollToRank = true; - } - } - } - - } - - /** - * Set the spinners for the leaderboard filters - */ - private void setSpinners() { - ArrayAdapter categoryAdapter = ArrayAdapter.createFromResource(getContext(), - R.array.leaderboard_categories, android.R.layout.simple_spinner_item); - categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - binding.categorySpinner.setAdapter(categoryAdapter); - - ArrayAdapter durationAdapter = ArrayAdapter.createFromResource(getContext(), - R.array.leaderboard_durations, android.R.layout.simple_spinner_item); - durationAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - binding.durationSpinner.setAdapter(durationAdapter); - } - - /** - * To call the API to get results - * which then sets the views using setLeaderboardUser method - */ - private void setLeaderboard(String duration, String category, int limit, int offset) { - if (checkAccount()) { - try { - compositeDisposable.add(okHttpJsonApiClient - .getLeaderboard(Objects.requireNonNull(userName), - duration, category, null, null) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - response -> { - if (response != null && response.getStatus() == 200) { - userRank = response.getRank(); - setViews(response, duration, category, limit, offset); - } - }, - t -> { - Timber.e(t, "Fetching leaderboard statistics failed"); - onError(); - } - )); - } - catch (Exception e){ - Timber.d(e+"success"); - } - } - } - - /** - * Set the views - * @param response Leaderboard Response Object - */ - private void setViews(LeaderboardResponse response, String duration, String category, int limit, int offset) { - viewModel = new ViewModelProvider(this, viewModelFactory).get(LeaderboardListViewModel.class); - viewModel.setParams(duration, category, limit, offset); - LeaderboardListAdapter leaderboardListAdapter = new LeaderboardListAdapter(); - UserDetailAdapter userDetailAdapter= new UserDetailAdapter(response); - MergeAdapter mergeAdapter = new MergeAdapter(userDetailAdapter, leaderboardListAdapter); - LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getContext()); - binding.leaderboardList.setLayoutManager(linearLayoutManager); - binding.leaderboardList.setAdapter(mergeAdapter); - viewModel.getListLiveData().observe(getViewLifecycleOwner(), leaderboardListAdapter::submitList); - viewModel.getProgressLoadStatus().observe(getViewLifecycleOwner(), status -> { - if (Objects.requireNonNull(status).equalsIgnoreCase(LOADING)) { - showProgressBar(); - } else if (status.equalsIgnoreCase(LOADED)) { - hideProgressBar(); - if (scrollToRank) { - binding.leaderboardList.smoothScrollToPosition(userRank + 1); - } - } - }); - } - - /** - * to hide progressbar - */ - private void hideProgressBar() { - if (binding != null) { - binding.progressBar.setVisibility(View.GONE); - binding.categorySpinner.setVisibility(View.VISIBLE); - binding.durationSpinner.setVisibility(View.VISIBLE); - binding.scroll.setVisibility(View.VISIBLE); - binding.leaderboardList.setVisibility(View.VISIBLE); - } - } - - /** - * to show progressbar - */ - private void showProgressBar() { - if (binding != null) { - binding.progressBar.setVisibility(View.VISIBLE); - binding.scroll.setVisibility(View.INVISIBLE); - } - } - - /** - * used to hide the layouts while fetching results from api - */ - private void hideLayouts(){ - binding.categorySpinner.setVisibility(View.INVISIBLE); - binding.durationSpinner.setVisibility(View.INVISIBLE); - binding.leaderboardList.setVisibility(View.INVISIBLE); - } - - /** - * check to ensure that user is logged in - * @return - */ - private boolean checkAccount(){ - Account currentAccount = sessionManager.getCurrentAccount(); - if (currentAccount == null) { - Timber.d("Current account is null"); - ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.user_not_logged_in)); - sessionManager.forceLogin(getActivity()); - return false; - } - return true; - } - - /** - * Shows a generic error toast when error occurs while loading leaderboard - */ - private void onError() { - ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.error_occurred)); - if (binding!=null) { - binding.progressBar.setVisibility(View.GONE); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - compositeDisposable.clear(); - binding = null; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.kt new file mode 100644 index 000000000..e77c24c8d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.kt @@ -0,0 +1,319 @@ +package fr.free.nrw.commons.profile.leaderboard + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.MergeAdapter +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.databinding.FragmentLeaderboardBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus.LOADED +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus.LOADING +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.PAGE_SIZE +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.START_OFFSET +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Objects +import javax.inject.Inject + +/** + * This class extends the CommonsDaggerSupportFragment and creates leaderboard fragment + */ +class LeaderboardFragment : CommonsDaggerSupportFragment() { + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var okHttpJsonApiClient: OkHttpJsonApiClient + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private var viewModel: LeaderboardListViewModel? = null + private var duration: String? = null + private var category: String? = null + private val limit: Int = PAGE_SIZE + private val offset: Int = START_OFFSET + private var userRank = 0 + private var scrollToRank = false + private var userName: String? = null + private var binding: FragmentLeaderboardBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { userName = it.getString(ProfileActivity.KEY_USERNAME) } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentLeaderboardBinding.inflate(inflater, container, false) + + hideLayouts() + + // Leaderboard currently unimplemented in Beta flavor. Skip all API calls and disable menu + if (isBetaFlavour) { + binding!!.progressBar.visibility = View.GONE + binding!!.scroll.visibility = View.GONE + return binding!!.root + } + + binding!!.progressBar.visibility = View.VISIBLE + setSpinners() + + /* + * This array is for the duration filter, we have three filters weekly, yearly and all-time + * each filter have a key and value pair, the value represents the param of the API + */ + val durationValues = requireContext().resources + .getStringArray(R.array.leaderboard_duration_values) + duration = durationValues[0] + + /* + * This array is for the category filter, we have three filters upload, used and nearby + * each filter have a key and value pair, the value represents the param of the API + */ + val categoryValues = requireContext().resources + .getStringArray(R.array.leaderboard_category_values) + category = categoryValues[0] + + setLeaderboard(duration, category, limit, offset) + + with(binding!!) { + durationSpinner.onItemSelectedListener = SelectionListener { + duration = durationValues[durationSpinner.selectedItemPosition] + refreshLeaderboard() + } + + categorySpinner.onItemSelectedListener = SelectionListener { + category = categoryValues[categorySpinner.selectedItemPosition] + refreshLeaderboard() + } + + scroll.setOnClickListener { scrollToUserRank() } + + return root + } + } + + override fun setMenuVisibility(visible: Boolean) { + super.setMenuVisibility(visible) + + // Whenever this fragment is revealed in a menu, + // notify Beta users the page data is unavailable + if (isBetaFlavour && visible) { + val ctx: Context? = if (context != null) { + context + } else if (view != null && requireView().context != null) { + requireView().context + } else { + null + } + + ctx?.let { + Toast.makeText(it, R.string.leaderboard_unavailable_beta, Toast.LENGTH_LONG).show() + } + } + } + + /** + * Refreshes the leaderboard list + */ + private fun refreshLeaderboard() { + scrollToRank = false + viewModel?.let { + it.refresh(duration, category, limit, offset) + setLeaderboard(duration, category, limit, offset) + } + } + + /** + * Performs Auto Scroll to the User's Rank + * We use userRank+1 to load one extra user and prevent overlapping of my rank button + * If you are viewing the leaderboard below userRank, it scrolls to the user rank at the top + */ + private fun scrollToUserRank() { + if (userRank == 0) { + Toast.makeText(context, R.string.no_achievements_yet, Toast.LENGTH_SHORT).show() + } else { + if (binding == null) { + return + } + val itemCount = binding?.leaderboardList?.adapter?.itemCount ?: 0 + if (itemCount > userRank + 1) { + binding!!.leaderboardList.smoothScrollToPosition(userRank + 1) + } else { + viewModel?.let { + it.refresh(duration, category, userRank + 1, 0) + setLeaderboard(duration, category, userRank + 1, 0) + scrollToRank = true + } + } + } + } + + /** + * Set the spinners for the leaderboard filters + */ + private fun setSpinners() { + val categoryAdapter = ArrayAdapter.createFromResource( + requireContext(), + R.array.leaderboard_categories, android.R.layout.simple_spinner_item + ) + categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding!!.categorySpinner.adapter = categoryAdapter + + val durationAdapter = ArrayAdapter.createFromResource( + requireContext(), + R.array.leaderboard_durations, android.R.layout.simple_spinner_item + ) + durationAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding!!.durationSpinner.adapter = durationAdapter + } + + /** + * To call the API to get results + * which then sets the views using setLeaderboardUser method + */ + private fun setLeaderboard(duration: String?, category: String?, limit: Int, offset: Int) { + if (checkAccount()) { + try { + compositeDisposable.add( + okHttpJsonApiClient.getLeaderboard( + Objects.requireNonNull(userName), + duration, category, null, null + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response: LeaderboardResponse? -> + if (response != null && response.status == 200) { + userRank = response.rank!! + setViews(response, duration, category, limit, offset) + } + }, + { t: Throwable? -> + Timber.e(t, "Fetching leaderboard statistics failed") + onError() + } + )) + } catch (e: Exception) { + Timber.d(e, "success") + } + } + } + + /** + * Set the views + * @param response Leaderboard Response Object + */ + private fun setViews( + response: LeaderboardResponse, + duration: String?, + category: String?, + limit: Int, + offset: Int + ) { + viewModel = ViewModelProvider(this, viewModelFactory).get( + LeaderboardListViewModel::class.java + ) + viewModel!!.setParams(duration, category, limit, offset) + val leaderboardListAdapter = LeaderboardListAdapter() + val userDetailAdapter = UserDetailAdapter(response) + val mergeAdapter = MergeAdapter(userDetailAdapter, leaderboardListAdapter) + val linearLayoutManager = LinearLayoutManager(context) + binding!!.leaderboardList.layoutManager = linearLayoutManager + binding!!.leaderboardList.adapter = mergeAdapter + viewModel!!.listLiveData.observe(viewLifecycleOwner, leaderboardListAdapter::submitList) + + viewModel!!.progressLoadStatus.observe(viewLifecycleOwner) { status -> + when (status) { + LOADING -> { + showProgressBar() + } + LOADED -> { + hideProgressBar() + if (scrollToRank) { + binding!!.leaderboardList.smoothScrollToPosition(userRank + 1) + } + } + } + } + } + + /** + * to hide progressbar + */ + private fun hideProgressBar() = binding?.let { + it.progressBar.visibility = View.GONE + it.categorySpinner.visibility = View.VISIBLE + it.durationSpinner.visibility = View.VISIBLE + it.scroll.visibility = View.VISIBLE + it.leaderboardList.visibility = View.VISIBLE + } + + /** + * to show progressbar + */ + private fun showProgressBar() = binding?.let { + it.progressBar.visibility = View.VISIBLE + it.scroll.visibility = View.INVISIBLE + } + + /** + * used to hide the layouts while fetching results from api + */ + private fun hideLayouts() = binding?.let { + it.categorySpinner.visibility = View.INVISIBLE + it.durationSpinner.visibility = View.INVISIBLE + it.leaderboardList.visibility = View.INVISIBLE + } + + /** + * check to ensure that user is logged in + */ + private fun checkAccount() = if (sessionManager.currentAccount == null) { + Timber.d("Current account is null") + showLongToast(requireActivity(), resources.getString(R.string.user_not_logged_in)) + sessionManager.forceLogin(requireActivity()) + false + } else { + true + } + + /** + * Shows a generic error toast when error occurs while loading leaderboard + */ + private fun onError() { + showLongToast(requireActivity(), resources.getString(R.string.error_occurred)) + binding?.let { it.progressBar.visibility = View.GONE } + } + + override fun onDestroy() { + super.onDestroy() + compositeDisposable.clear() + binding = null + } + + private class SelectionListener(private val handler: () -> Unit): AdapterView.OnItemSelectedListener { + override fun onItemSelected(adapterView: AdapterView<*>?, view: View, i: Int, l: Long) = + handler() + + override fun onNothingSelected(p0: AdapterView<*>?) = Unit + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.java deleted file mode 100644 index 5558f3d9e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.java +++ /dev/null @@ -1,137 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.DiffUtil.ItemCallback; -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -/** - * This class represents the leaderboard API response sub part of i.e. leaderboard list - * The leaderboard list will contain the ranking of the users from 1 to n, - * avatars, username and count in the selected category. - */ -public class LeaderboardList { - - /** - * Username of the user - * Example value - Syced - */ - @SerializedName("username") - @Expose - private String username; - - /** - * Count in the category - * Example value - 10 - */ - @SerializedName("category_count") - @Expose - private Integer categoryCount; - - /** - * URL of the avatar of user - * Example value = https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/Gnome-stock_person.svg/200px-Gnome-stock_person.svg.png - */ - @SerializedName("avatar") - @Expose - private String avatar; - - /** - * Rank of the user - * Example value - 1 - */ - @SerializedName("rank") - @Expose - private Integer rank; - - /** - * @return the username of the user in the leaderboard list - */ - public String getUsername() { - return username; - } - - /** - * Sets the username of the user in the leaderboard list - */ - public void setUsername(String username) { - this.username = username; - } - - /** - * @return the category count of the user in the leaderboard list - */ - public Integer getCategoryCount() { - return categoryCount; - } - - /** - * Sets the category count of the user in the leaderboard list - */ - public void setCategoryCount(Integer categoryCount) { - this.categoryCount = categoryCount; - } - - /** - * @return the avatar of the user in the leaderboard list - */ - public String getAvatar() { - return avatar; - } - - /** - * Sets the avatar of the user in the leaderboard list - */ - public void setAvatar(String avatar) { - this.avatar = avatar; - } - - /** - * @return the rank of the user in the leaderboard list - */ - public Integer getRank() { - return rank; - } - - /** - * Sets the rank of the user in the leaderboard list - */ - public void setRank(Integer rank) { - this.rank = rank; - } - - - /** - * This method checks for the diff in the callbacks for paged lists - */ - public static DiffUtil.ItemCallback DIFF_CALLBACK = - new ItemCallback() { - @Override - public boolean areItemsTheSame(@NonNull LeaderboardList oldItem, - @NonNull LeaderboardList newItem) { - return newItem == oldItem; - } - - @Override - public boolean areContentsTheSame(@NonNull LeaderboardList oldItem, - @NonNull LeaderboardList newItem) { - return newItem.getRank().equals(oldItem.getRank()); - } - }; - - /** - * Returns true if two objects are equal, false otherwise - * @param obj - * @return - */ - @Override - public boolean equals(Object obj) { - if (obj == this) { - return true; - } - - LeaderboardList leaderboardList = (LeaderboardList) obj; - return leaderboardList.getRank().equals(this.getRank()); - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.kt new file mode 100644 index 000000000..dc6d93e15 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.kt @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.profile.leaderboard + +import androidx.recyclerview.widget.DiffUtil +import com.google.gson.annotations.SerializedName + +/** + * This class represents the leaderboard API response sub part of i.e. leaderboard list + * The leaderboard list will contain the ranking of the users from 1 to n, + * avatars, username and count in the selected category. + */ +data class LeaderboardList ( + @SerializedName("username") + var username: String? = null, + @SerializedName("category_count") + var categoryCount: Int? = null, + @SerializedName("avatar") + var avatar: String? = null, + @SerializedName("rank") + var rank: Int? = null +) { + + /** + * Returns true if two objects are equal, false otherwise + * @param other + * @return + */ + override fun equals(other: Any?): Boolean { + if (other === this) { + return true + } + + val leaderboardList = other as LeaderboardList + return leaderboardList.rank == rank + } + + override fun hashCode(): Int { + var result = username?.hashCode() ?: 0 + result = 31 * result + (categoryCount ?: 0) + result = 31 * result + (avatar?.hashCode() ?: 0) + result = 31 * result + (rank ?: 0) + return result + } + + companion object { + /** + * This method checks for the diff in the callbacks for paged lists + */ + var DIFF_CALLBACK: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: LeaderboardList, + newItem: LeaderboardList + ): Boolean = newItem === oldItem + + override fun areContentsTheSame( + oldItem: LeaderboardList, + newItem: LeaderboardList + ): Boolean = newItem.rank == oldItem.rank + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.java deleted file mode 100644 index 9af24159a..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.java +++ /dev/null @@ -1,93 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - - -import android.app.Activity; -import android.content.Context; -import android.net.Uri; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.paging.PagedListAdapter; -import androidx.recyclerview.widget.RecyclerView; -import com.facebook.drawee.view.SimpleDraweeView; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.profile.ProfileActivity; - -/** - * This class extends RecyclerView.Adapter and creates the List section of the leaderboard - */ -public class LeaderboardListAdapter extends PagedListAdapter { - - public LeaderboardListAdapter() { - super(LeaderboardList.DIFF_CALLBACK); - } - - public class ListViewHolder extends RecyclerView.ViewHolder { - TextView rank; - SimpleDraweeView avatar; - TextView username; - TextView count; - - public ListViewHolder(View itemView) { - super(itemView); - this.rank = itemView.findViewById(R.id.user_rank); - this.avatar = itemView.findViewById(R.id.user_avatar); - this.username = itemView.findViewById(R.id.user_name); - this.count = itemView.findViewById(R.id.user_count); - } - - /** - * This method will return the Context - * @return Context - */ - public Context getContext() { - return itemView.getContext(); - } - } - - /** - * Overrides the onCreateViewHolder and inflates the recyclerview list item layout - * @param parent - * @param viewType - * @return - */ - @NonNull - @Override - public LeaderboardListAdapter.ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.leaderboard_list_element, parent, false); - - return new ListViewHolder(view); - } - - /** - * Overrides the onBindViewHolder Set the view at the specific position with the specific value - * @param holder - * @param position - */ - @Override - public void onBindViewHolder(@NonNull LeaderboardListAdapter.ListViewHolder holder, int position) { - TextView rank = holder.rank; - SimpleDraweeView avatar = holder.avatar; - TextView username = holder.username; - TextView count = holder.count; - - rank.setText(getItem(position).getRank().toString()); - - avatar.setImageURI(Uri.parse(getItem(position).getAvatar())); - username.setText(getItem(position).getUsername()); - count.setText(getItem(position).getCategoryCount().toString()); - - /* - Now that we have our in app profile-section, lets take the user there - */ - holder.itemView.setOnClickListener(view -> { - if (view.getContext() instanceof ProfileActivity) { - ((Activity) (view.getContext())).finish(); - } - ProfileActivity.startYourself(view.getContext(), getItem(position).getUsername(), true); - }); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt new file mode 100644 index 000000000..c7bccf950 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt @@ -0,0 +1,64 @@ +package fr.free.nrw.commons.profile.leaderboard + +import android.app.Activity +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.facebook.drawee.view.SimpleDraweeView +import fr.free.nrw.commons.R +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.profile.leaderboard.LeaderboardList.Companion.DIFF_CALLBACK +import fr.free.nrw.commons.profile.leaderboard.LeaderboardListAdapter.ListViewHolder + + +/** + * This class extends RecyclerView.Adapter and creates the List section of the leaderboard + */ +class LeaderboardListAdapter : PagedListAdapter(DIFF_CALLBACK) { + inner class ListViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + var rank: TextView? = itemView.findViewById(R.id.user_rank) + var avatar: SimpleDraweeView? = itemView.findViewById(R.id.user_avatar) + var username: TextView? = itemView.findViewById(R.id.user_name) + var count: TextView? = itemView.findViewById(R.id.user_count) + } + + /** + * Overrides the onCreateViewHolder and inflates the recyclerview list item layout + * @param parent + * @param viewType + * @return + */ + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder = + ListViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.leaderboard_list_element, parent, false) + ) + + /** + * Overrides the onBindViewHolder Set the view at the specific position with the specific value + * @param holder + * @param position + */ + override fun onBindViewHolder(holder: ListViewHolder, position: Int) = with (holder) { + val item = getItem(position)!! + + rank?.text = item.rank.toString() + avatar?.setImageURI(Uri.parse(item.avatar)) + username?.text = item.username + count?.text = item.categoryCount.toString() + + /* + Now that we have our in app profile-section, lets take the user there + */ + itemView.setOnClickListener { view: View -> + if (view.context is ProfileActivity) { + ((view.context) as Activity).finish() + } + ProfileActivity.startYourself(view.context, item.username, true) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.java deleted file mode 100644 index 909b4f646..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.java +++ /dev/null @@ -1,107 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.PAGE_SIZE; - -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import androidx.lifecycle.ViewModel; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import io.reactivex.disposables.CompositeDisposable; - -/** - * Extends the ViewModel class and creates the LeaderboardList View Model - */ -public class LeaderboardListViewModel extends ViewModel { - - private DataSourceFactory dataSourceFactory; - private LiveData> listLiveData; - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - private LiveData progressLoadStatus = new MutableLiveData<>(); - - /** - * Constructor for a new LeaderboardListViewModel - * @param okHttpJsonApiClient - * @param sessionManager - */ - public LeaderboardListViewModel(OkHttpJsonApiClient okHttpJsonApiClient, SessionManager - sessionManager) { - - dataSourceFactory = new DataSourceFactory(okHttpJsonApiClient, - compositeDisposable, sessionManager); - initializePaging(); - } - - - /** - * Initialises the paging - */ - private void initializePaging() { - - PagedList.Config pagedListConfig = - new PagedList.Config.Builder() - .setEnablePlaceholders(false) - .setInitialLoadSizeHint(PAGE_SIZE) - .setPageSize(PAGE_SIZE).build(); - - listLiveData = new LivePagedListBuilder<>(dataSourceFactory, pagedListConfig) - .build(); - - progressLoadStatus = Transformations - .switchMap(dataSourceFactory.getMutableLiveData(), DataSourceClass::getProgressLiveStatus); - - } - - /** - * Refreshes the paged list with the new params and starts the loading of new data - * @param duration - * @param category - * @param limit - * @param offset - */ - public void refresh(String duration, String category, int limit, int offset) { - dataSourceFactory.setDuration(duration); - dataSourceFactory.setCategory(category); - dataSourceFactory.setLimit(limit); - dataSourceFactory.setOffset(offset); - dataSourceFactory.getMutableLiveData().getValue().invalidate(); - } - - /** - * Sets the new params for the paged list API calls - * @param duration - * @param category - * @param limit - * @param offset - */ - public void setParams(String duration, String category, int limit, int offset) { - dataSourceFactory.setDuration(duration); - dataSourceFactory.setCategory(category); - dataSourceFactory.setLimit(limit); - dataSourceFactory.setOffset(offset); - } - - /** - * @return the loading status of paged list - */ - public LiveData getProgressLoadStatus() { - return progressLoadStatus; - } - - /** - * @return the paged list with live data - */ - public LiveData> getListLiveData() { - return listLiveData; - } - - @Override - protected void onCleared() { - super.onCleared(); - compositeDisposable.clear(); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.kt new file mode 100644 index 000000000..7d649b67b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.kt @@ -0,0 +1,54 @@ +package fr.free.nrw.commons.profile.leaderboard + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.PAGE_SIZE + +/** + * Extends the ViewModel class and creates the LeaderboardList View Model + */ +class LeaderboardListViewModel( + okHttpJsonApiClient: OkHttpJsonApiClient, + sessionManager: SessionManager +) : ViewModel() { + private val dataSourceFactory = DataSourceFactory(okHttpJsonApiClient, sessionManager) + + val listLiveData: LiveData> = LivePagedListBuilder( + dataSourceFactory, + PagedList.Config.Builder() + .setEnablePlaceholders(false) + .setInitialLoadSizeHint(PAGE_SIZE) + .setPageSize(PAGE_SIZE).build() + ).build() + + val progressLoadStatus: LiveData = + dataSourceFactory.mutableLiveData.switchMap { it.progressLiveStatus } + + /** + * Refreshes the paged list with the new params and starts the loading of new data + */ + fun refresh(duration: String?, category: String?, limit: Int, offset: Int) { + dataSourceFactory.duration = duration + dataSourceFactory.category = category + dataSourceFactory.limit = limit + dataSourceFactory.offset = offset + dataSourceFactory.mutableLiveData.value!!.invalidate() + } + + /** + * Sets the new params for the paged list API calls + */ + fun setParams(duration: String?, category: String?, limit: Int, offset: Int) { + dataSourceFactory.duration = duration + dataSourceFactory.category = category + dataSourceFactory.limit = limit + dataSourceFactory.offset = offset + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.java deleted file mode 100644 index 34294fca9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.java +++ /dev/null @@ -1,237 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import java.util.List; -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -/** - * GSON Response Class for Leaderboard API response - */ -public class LeaderboardResponse { - - /** - * Status Code returned from the API - * Example value - 200 - */ - @SerializedName("status") - @Expose - private Integer status; - - /** - * Username returned from the API - * Example value - Syced - */ - @SerializedName("username") - @Expose - private String username; - - /** - * Category count returned from the API - * Example value - 10 - */ - @SerializedName("category_count") - @Expose - private Integer categoryCount; - - /** - * Limit returned from the API - * Example value - 10 - */ - @SerializedName("limit") - @Expose - private int limit; - - /** - * Avatar returned from the API - * Example value - https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/Gnome-stock_person.svg/200px-Gnome-stock_person.svg.png - */ - @SerializedName("avatar") - @Expose - private String avatar; - - /** - * Offset returned from the API - * Example value - 0 - */ - @SerializedName("offset") - @Expose - private int offset; - - /** - * Duration returned from the API - * Example value - yearly - */ - @SerializedName("duration") - @Expose - private String duration; - - /** - * Leaderboard list returned from the API - * Example value - [{ - * "username": "Fæ", - * "category_count": 107147, - * "avatar": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/Gnome-stock_person.svg/200px-Gnome-stock_person.svg.png", - * "rank": 1 - * }] - */ - @SerializedName("leaderboard_list") - @Expose - private List leaderboardList = null; - - /** - * Category returned from the API - * Example value - upload - */ - @SerializedName("category") - @Expose - private String category; - - /** - * Rank returned from the API - * Example value - 1 - */ - @SerializedName("rank") - @Expose - private Integer rank; - - /** - * @return the status code - */ - public Integer getStatus() { - return status; - } - - /** - * Sets the status code - */ - public void setStatus(Integer status) { - this.status = status; - } - - /** - * @return the username - */ - public String getUsername() { - return username; - } - - /** - * Sets the username - */ - public void setUsername(String username) { - this.username = username; - } - - /** - * @return the category count - */ - public Integer getCategoryCount() { - return categoryCount; - } - - /** - * Sets the category count - */ - public void setCategoryCount(Integer categoryCount) { - this.categoryCount = categoryCount; - } - - /** - * @return the limit - */ - public int getLimit() { - return limit; - } - - /** - * Sets the limit - */ - public void setLimit(int limit) { - this.limit = limit; - } - - /** - * @return the avatar - */ - public String getAvatar() { - return avatar; - } - - /** - * Sets the avatar - */ - public void setAvatar(String avatar) { - this.avatar = avatar; - } - - /** - * @return the offset - */ - public int getOffset() { - return offset; - } - - /** - * Sets the offset - */ - public void setOffset(int offset) { - this.offset = offset; - } - - /** - * @return the duration - */ - public String getDuration() { - return duration; - } - - /** - * Sets the duration - */ - public void setDuration(String duration) { - this.duration = duration; - } - - /** - * @return the leaderboard list - */ - public List getLeaderboardList() { - return leaderboardList; - } - - /** - * Sets the leaderboard list - */ - public void setLeaderboardList(List leaderboardList) { - this.leaderboardList = leaderboardList; - } - - /** - * @return the category - */ - public String getCategory() { - return category; - } - - /** - * Sets the category - */ - public void setCategory(String category) { - this.category = category; - } - - /** - * @return the rank - */ - public Integer getRank() { - return rank; - } - - /** - * Sets the rank - */ - public void setRank(Integer rank) { - this.rank = rank; - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.kt new file mode 100644 index 000000000..8be342650 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons.profile.leaderboard + +import com.google.gson.annotations.SerializedName + +/** + * GSON Response Class for Leaderboard API response + */ +data class LeaderboardResponse( + @SerializedName("status") var status: Int? = null, + @SerializedName("username") var username: String? = null, + @SerializedName("category_count") var categoryCount: Int? = null, + @SerializedName("limit") var limit: Int = 0, + @SerializedName("avatar") var avatar: String? = null, + @SerializedName("offset") var offset: Int = 0, + @SerializedName("duration") var duration: String? = null, + @SerializedName("leaderboard_list") var leaderboardList: List? = null, + @SerializedName("category") var category: String? = null, + @SerializedName("rank") var rank: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.java deleted file mode 100644 index 15449a488..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.java +++ /dev/null @@ -1,77 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -/** - * GSON Response Class for Update Avatar API response - */ -public class UpdateAvatarResponse { - - /** - * Status Code returned from the API - * Example value - 200 - */ - @SerializedName("status") - @Expose - private String status; - - /** - * Message returned from the API - * Example value - Avatar Updated - */ - @SerializedName("message") - @Expose - private String message; - - /** - * Username returned from the API - * Example value - Syced - */ - @SerializedName("user") - @Expose - private String user; - - /** - * @return the status code - */ - public String getStatus() { - return status; - } - - /** - * Sets the status code - */ - public void setStatus(String status) { - this.status = status; - } - - /** - * @return the message - */ - public String getMessage() { - return message; - } - - /** - * Sets the message - */ - public void setMessage(String message) { - this.message = message; - } - - /** - * @return the username - */ - public String getUser() { - return user; - } - - /** - * Sets the username - */ - public void setUser(String user) { - this.user = user; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.kt new file mode 100644 index 000000000..75fb8f268 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.kt @@ -0,0 +1,10 @@ +package fr.free.nrw.commons.profile.leaderboard + +/** + * GSON Response Class for Update Avatar API response + */ +data class UpdateAvatarResponse( + var status: String? = null, + var message: String? = null, + var user: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.java deleted file mode 100644 index 75b9de938..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.java +++ /dev/null @@ -1,126 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.content.Context; -import android.net.Uri; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import com.facebook.drawee.view.SimpleDraweeView; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.R; - - -/** - * This class extends RecyclerView.Adapter and creates the UserDetail section of the leaderboard - */ -public class UserDetailAdapter extends RecyclerView.Adapter { - - private LeaderboardResponse leaderboardResponse; - - /** - * Stores the username of currently logged in user. - */ - private String currentlyLoggedInUserName = null; - - public UserDetailAdapter(LeaderboardResponse leaderboardResponse) { - this.leaderboardResponse = leaderboardResponse; - } - - public class DataViewHolder extends RecyclerView.ViewHolder { - - private TextView rank; - private SimpleDraweeView avatar; - private TextView username; - private TextView count; - - public DataViewHolder(@NonNull View itemView) { - super(itemView); - this.rank = itemView.findViewById(R.id.rank); - this.avatar = itemView.findViewById(R.id.avatar); - this.username = itemView.findViewById(R.id.username); - this.count = itemView.findViewById(R.id.count); - } - - /** - * This method will return the Context - * @return Context - */ - public Context getContext() { - return itemView.getContext(); - } - } - - /** - * Overrides the onCreateViewHolder and sets the view with leaderboard user element layout - * @param parent - * @param viewType - * @return - */ - @NonNull - @Override - public UserDetailAdapter.DataViewHolder onCreateViewHolder(@NonNull ViewGroup parent, - int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.leaderboard_user_element, parent, false); - return new DataViewHolder(view); - } - - /** - * Overrides the onBindViewHolder Set the view at the specific position with the specific value - * @param holder - * @param position - */ - @Override - public void onBindViewHolder(@NonNull UserDetailAdapter.DataViewHolder holder, int position) { - TextView rank = holder.rank; - SimpleDraweeView avatar = holder.avatar; - TextView username = holder.username; - TextView count = holder.count; - - rank.setText(String.format("%s %d", - holder.getContext().getResources().getString(R.string.rank_prefix), - leaderboardResponse.getRank())); - - avatar.setImageURI( - Uri.parse(leaderboardResponse.getAvatar())); - username.setText(leaderboardResponse.getUsername()); - count.setText(String.format("%s %d", - holder.getContext().getResources().getString(R.string.count_prefix), - leaderboardResponse.getCategoryCount())); - - // When user tap on avatar shows the toast on how to change avatar - // fixing: https://github.com/commons-app/apps-android-commons/issues/47747 - if (currentlyLoggedInUserName == null) { - // If the current login username has not been fetched yet, then fetch it. - final AccountManager accountManager = AccountManager.get(username.getContext()); - final Account[] allAccounts = accountManager.getAccountsByType( - BuildConfig.ACCOUNT_TYPE); - if (allAccounts.length != 0) { - currentlyLoggedInUserName = allAccounts[0].name; - } - } - if (currentlyLoggedInUserName != null && currentlyLoggedInUserName.equals( - leaderboardResponse.getUsername())) { - - avatar.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - Toast.makeText(v.getContext(), - R.string.set_up_avatar_toast_string, - Toast.LENGTH_LONG).show(); - } - }); - } - } - - @Override - public int getItemCount() { - return 1; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt new file mode 100644 index 000000000..34fd5ab58 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt @@ -0,0 +1,91 @@ +package fr.free.nrw.commons.profile.leaderboard + +import android.accounts.AccountManager +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.recyclerview.widget.RecyclerView +import com.facebook.drawee.view.SimpleDraweeView +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.R +import fr.free.nrw.commons.profile.leaderboard.UserDetailAdapter.DataViewHolder +import java.util.Locale + +/** + * This class extends RecyclerView.Adapter and creates the UserDetail section of the leaderboard + */ +class UserDetailAdapter(private val leaderboardResponse: LeaderboardResponse) : + RecyclerView.Adapter() { + /** + * Stores the username of currently logged in user. + */ + private var currentlyLoggedInUserName: String? = null + + class DataViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val rank: TextView = itemView.findViewById(R.id.rank) + val avatar: SimpleDraweeView = itemView.findViewById(R.id.avatar) + val username: TextView = itemView.findViewById(R.id.username) + val count: TextView = itemView.findViewById(R.id.count) + } + + /** + * Overrides the onCreateViewHolder and sets the view with leaderboard user element layout + * @param parent + * @param viewType + * @return + */ + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): DataViewHolder = DataViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.leaderboard_user_element, parent, false) + ) + + /** + * Overrides the onBindViewHolder Set the view at the specific position with the specific value + * @param holder + * @param position + */ + override fun onBindViewHolder(holder: DataViewHolder, position: Int) = with(holder) { + val resources = itemView.context.resources + + avatar.setImageURI(Uri.parse(leaderboardResponse.avatar)) + username.text = leaderboardResponse.username + rank.text = String.format( + Locale.getDefault(), + "%s %d", + resources.getString(R.string.rank_prefix), + leaderboardResponse.rank + ) + count.text = String.format( + Locale.getDefault(), + "%s %d", + resources.getString(R.string.count_prefix), + leaderboardResponse.categoryCount + ) + + // When user tap on avatar shows the toast on how to change avatar + // fixing: https://github.com/commons-app/apps-android-commons/issues/47747 + if (currentlyLoggedInUserName == null) { + // If the current login username has not been fetched yet, then fetch it. + val accountManager = AccountManager.get(itemView.context) + val allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE) + if (allAccounts.isNotEmpty()) { + currentlyLoggedInUserName = allAccounts[0].name + } + } + if (currentlyLoggedInUserName != null && currentlyLoggedInUserName == leaderboardResponse.username) { + avatar.setOnClickListener { v: View -> + Toast.makeText( + v.context, R.string.set_up_avatar_toast_string, Toast.LENGTH_LONG + ).show() + } + } + } + + override fun getItemCount(): Int = 1 +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.java deleted file mode 100644 index fece77110..000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.java +++ /dev/null @@ -1,41 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import androidx.annotation.NonNull; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import javax.inject.Inject; - -/** - * This class extends the ViewModelProvider.Factory and creates a ViewModelFactory class - * for leaderboardListViewModel - */ -public class ViewModelFactory implements ViewModelProvider.Factory { - - private OkHttpJsonApiClient okHttpJsonApiClient; - private SessionManager sessionManager; - - - @Inject - public ViewModelFactory(OkHttpJsonApiClient okHttpJsonApiClient, SessionManager sessionManager) { - this.okHttpJsonApiClient = okHttpJsonApiClient; - this.sessionManager = sessionManager; - } - - - /** - * Creats a new LeaderboardListViewModel - * @param modelClass - * @param - * @return - */ - @NonNull - @Override - public T create(@NonNull Class modelClass) { - if (modelClass.isAssignableFrom(LeaderboardListViewModel.class)) { - return (T) new LeaderboardListViewModel(okHttpJsonApiClient, sessionManager); - } - throw new IllegalArgumentException("Unknown class name"); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.kt new file mode 100644 index 000000000..f325355e0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.kt @@ -0,0 +1,26 @@ +package fr.free.nrw.commons.profile.leaderboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import javax.inject.Inject + + +/** + * This class extends the ViewModelProvider.Factory and creates a ViewModelFactory class + * for leaderboardListViewModel + */ +class ViewModelFactory @Inject constructor( + private val okHttpJsonApiClient: OkHttpJsonApiClient, + private val sessionManager: SessionManager +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + if (modelClass.isAssignableFrom(LeaderboardListViewModel::class.java)) { + LeaderboardListViewModel(okHttpJsonApiClient, sessionManager) as T + } else { + throw IllegalArgumentException("Unknown class name") + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.java b/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.java deleted file mode 100644 index 8c087b17b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.java +++ /dev/null @@ -1,146 +0,0 @@ -package fr.free.nrw.commons.quiz; - -import android.content.Intent; -import android.os.Bundle; - -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; - -import com.facebook.drawee.drawable.ProgressBarDrawable; -import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; - -import fr.free.nrw.commons.databinding.ActivityQuizBinding; -import java.util.ArrayList; - -import fr.free.nrw.commons.R; - -public class QuizActivity extends AppCompatActivity { - - private ActivityQuizBinding binding; - private final QuizController quizController = new QuizController(); - private ArrayList quiz = new ArrayList<>(); - private int questionIndex = 0; - private int score; - /** - * isPositiveAnswerChecked : represents yes click event - */ - private boolean isPositiveAnswerChecked; - /** - * isNegativeAnswerChecked : represents no click event - */ - private boolean isNegativeAnswerChecked; - - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = ActivityQuizBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - quizController.initialize(this); - setSupportActionBar(binding.toolbar.toolbar); - binding.nextButton.setOnClickListener(view -> notKnowAnswer()); - displayQuestion(); - } - - /** - * to move to next question and check whether answer is selected or not - */ - public void setNextQuestion(){ - if ( questionIndex <= quiz.size() && (isPositiveAnswerChecked || isNegativeAnswerChecked)) { - evaluateScore(); - } - } - - public void notKnowAnswer(){ - customAlert("Information", quiz.get(questionIndex).getAnswerMessage()); - } - - /** - * to give warning before ending quiz - */ - @Override - public void onBackPressed() { - new AlertDialog.Builder(this) - .setTitle(getResources().getString(R.string.warning)) - .setMessage(getResources().getString(R.string.quiz_back_button)) - .setPositiveButton(R.string.continue_message, (dialog, which) -> { - final Intent intent = new Intent(this, QuizResultActivity.class); - dialog.dismiss(); - intent.putExtra("QuizResult", score); - startActivity(intent); - }) - .setNegativeButton("Cancel", (dialogInterface, i) -> dialogInterface.dismiss()) - .create() - .show(); - } - - /** - * to display the question - */ - public void displayQuestion() { - quiz = quizController.getQuiz(); - binding.question.questionText.setText(quiz.get(questionIndex).getQuestion()); - binding.questionTitle.setText( - getResources().getString(R.string.question) + - quiz.get(questionIndex).getQuestionNumber() - ); - binding.question.questionImage.setHierarchy(GenericDraweeHierarchyBuilder - .newInstance(getResources()) - .setFailureImage(VectorDrawableCompat.create(getResources(), - R.drawable.ic_error_outline_black_24dp, getTheme())) - .setProgressBarImage(new ProgressBarDrawable()) - .build()); - - binding.question.questionImage.setImageURI(quiz.get(questionIndex).getUrl()); - isPositiveAnswerChecked = false; - isNegativeAnswerChecked = false; - binding.answer.quizPositiveAnswer.setOnClickListener(view -> { - isPositiveAnswerChecked = true; - setNextQuestion(); - }); - binding.answer.quizNegativeAnswer.setOnClickListener(view -> { - isNegativeAnswerChecked = true; - setNextQuestion(); - }); - } - - /** - * to evaluate score and check whether answer is correct or wrong - */ - public void evaluateScore() { - if ((quiz.get(questionIndex).isAnswer() && isPositiveAnswerChecked) || - (!quiz.get(questionIndex).isAnswer() && isNegativeAnswerChecked) ){ - customAlert(getResources().getString(R.string.correct), - quiz.get(questionIndex).getAnswerMessage()); - score++; - } else { - customAlert(getResources().getString(R.string.wrong), - quiz.get(questionIndex).getAnswerMessage()); - } - } - - /** - * to display explanation after each answer, update questionIndex and move to next question - * @param title the alert title - * @param Message the alert message - */ - public void customAlert(final String title, final String Message) { - new AlertDialog.Builder(this) - .setTitle(title) - .setMessage(Message) - .setPositiveButton(R.string.continue_message, (dialog, which) -> { - questionIndex++; - if (questionIndex == quiz.size()) { - final Intent intent = new Intent(this, QuizResultActivity.class); - dialog.dismiss(); - intent.putExtra("QuizResult", score); - startActivity(intent); - } else { - displayQuestion(); - } - }) - .create() - .show(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt b/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt new file mode 100644 index 000000000..e60d4685a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/quiz/QuizActivity.kt @@ -0,0 +1,156 @@ +package fr.free.nrw.commons.quiz + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle + +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat + +import com.facebook.drawee.drawable.ProgressBarDrawable +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder + +import fr.free.nrw.commons.databinding.ActivityQuizBinding +import java.util.ArrayList + +import fr.free.nrw.commons.R + + +class QuizActivity : AppCompatActivity() { + + private lateinit var binding: ActivityQuizBinding + private val quizController = QuizController() + private var quiz = ArrayList() + private var questionIndex = 0 + private var score = 0 + + /** + * isPositiveAnswerChecked : represents yes click event + */ + private var isPositiveAnswerChecked = false + + /** + * isNegativeAnswerChecked : represents no click event + */ + private var isNegativeAnswerChecked = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityQuizBinding.inflate(layoutInflater) + setContentView(binding.root) + + quizController.initialize(this) + setSupportActionBar(binding.toolbar.toolbar) + binding.nextButton.setOnClickListener { notKnowAnswer() } + displayQuestion() + } + + /** + * To move to next question and check whether answer is selected or not + */ + fun setNextQuestion() { + if (questionIndex <= quiz.size && (isPositiveAnswerChecked || isNegativeAnswerChecked)) { + evaluateScore() + } + } + + private fun notKnowAnswer() { + customAlert("Information", quiz[questionIndex].answerMessage) + } + + /** + * To give warning before ending quiz + */ + override fun onBackPressed() { + AlertDialog.Builder(this) + .setTitle(getString(R.string.warning)) + .setMessage(getString(R.string.quiz_back_button)) + .setCancelable(false) + .setPositiveButton(R.string.continue_message) { dialog, _ -> + val intent = Intent(this, QuizResultActivity::class.java) + dialog.dismiss() + intent.putExtra("QuizResult", score) + startActivity(intent) + } + .setNegativeButton("Cancel") { dialogInterface, _ -> dialogInterface.dismiss() } + .create() + .show() + } + + /** + * To display the question + */ + @SuppressLint("SetTextI18n") + private fun displayQuestion() { + quiz = quizController.getQuiz() + binding.question.questionText.text = quiz[questionIndex].question + binding.questionTitle.text = getString(R.string.question) + quiz[questionIndex].questionNumber + + binding.question.questionImage.hierarchy = GenericDraweeHierarchyBuilder + .newInstance(resources) + .setFailureImage(VectorDrawableCompat.create(resources, R.drawable.ic_error_outline_black_24dp, theme)) + .setProgressBarImage(ProgressBarDrawable()) + .build() + + binding.question.questionImage.setImageURI(quiz[questionIndex].getUrl()) + isPositiveAnswerChecked = false + isNegativeAnswerChecked = false + + binding.answer.quizPositiveAnswer.setOnClickListener { + isPositiveAnswerChecked = true + setNextQuestion() + } + binding.answer.quizNegativeAnswer.setOnClickListener { + isNegativeAnswerChecked = true + setNextQuestion() + } + } + + /** + * To evaluate score and check whether answer is correct or wrong + */ + fun evaluateScore() { + if ( + (quiz[questionIndex].isAnswer && isPositiveAnswerChecked) + || + (!quiz[questionIndex].isAnswer && isNegativeAnswerChecked) + ) { + customAlert( + getString(R.string.correct), + quiz[questionIndex].answerMessage + ) + score++ + } else { + customAlert( + getString(R.string.wrong), + quiz[questionIndex].answerMessage + ) + } + } + + /** + * To display explanation after each answer, update questionIndex and move to next question + * @param title The alert title + * @param message The alert message + */ + fun customAlert(title: String, message: String) { + AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message) + .setCancelable(false) + .setPositiveButton(R.string.continue_message) { dialog, _ -> + questionIndex++ + if (questionIndex == quiz.size) { + val intent = Intent(this, QuizResultActivity::class.java) + dialog.dismiss() + intent.putExtra("QuizResult", score) + startActivity(intent) + } else { + displayQuestion() + } + } + .create() + .show() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizChecker.java b/app/src/main/java/fr/free/nrw/commons/quiz/QuizChecker.java deleted file mode 100644 index 201c5bfc6..000000000 --- a/app/src/main/java/fr/free/nrw/commons/quiz/QuizChecker.java +++ /dev/null @@ -1,167 +0,0 @@ -package fr.free.nrw.commons.quiz; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.Intent; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.WelcomeActivity; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import fr.free.nrw.commons.utils.DialogUtil; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import timber.log.Timber; - -/** - * fetches the number of images uploaded and number of images reverted. - * Then it calculates the percentage of the images reverted - * if the percentage of images reverted after last quiz exceeds 50% and number of images uploaded is - * greater than 50, then quiz is popped up - */ -@Singleton -public class QuizChecker { - - private int revertCount ; - private int totalUploadCount ; - private boolean isRevertCountFetched; - private boolean isUploadCountFetched; - - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - - private final SessionManager sessionManager; - private final OkHttpJsonApiClient okHttpJsonApiClient; - private final JsonKvStore revertKvStore; - - private static final int UPLOAD_COUNT_THRESHOLD = 5; - private static final String REVERT_PERCENTAGE_FOR_MESSAGE = "50%"; - private final String REVERT_SHARED_PREFERENCE = "revertCount"; - private final String UPLOAD_SHARED_PREFERENCE = "uploadCount"; - - /** - * constructor to set the parameters for quiz - * @param sessionManager - * @param okHttpJsonApiClient - */ - @Inject - public QuizChecker(SessionManager sessionManager, - OkHttpJsonApiClient okHttpJsonApiClient, - @Named("default_preferences") JsonKvStore revertKvStore) { - this.sessionManager = sessionManager; - this.okHttpJsonApiClient = okHttpJsonApiClient; - this.revertKvStore = revertKvStore; - } - - public void initQuizCheck(Activity activity) { - calculateRevertParameterAndShowQuiz(activity); - } - - public void cleanup() { - compositeDisposable.clear(); - } - - /** - * to fet the total number of images uploaded - */ - private void setUploadCount() { - compositeDisposable.add(okHttpJsonApiClient - .getUploadCount(sessionManager.getUserName()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::setTotalUploadCount, - t -> Timber.e(t, "Fetching upload count failed") - )); - } - - /** - * set the sub Title of Contibutions Activity and - * call function to check for quiz - * @param uploadCount user's upload count - */ - private void setTotalUploadCount(int uploadCount) { - totalUploadCount = uploadCount - revertKvStore.getInt(UPLOAD_SHARED_PREFERENCE, 0); - if ( totalUploadCount < 0){ - totalUploadCount = 0; - revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0); - } - isUploadCountFetched = true; - } - - /** - * To call the API to get reverts count in form of JSONObject - */ - private void setRevertCount() { - compositeDisposable.add(okHttpJsonApiClient - .getAchievements(sessionManager.getUserName()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - response -> { - if (response != null) { - setRevertParameter(response.getDeletedUploads()); - } - }, throwable -> Timber.e(throwable, "Fetching feedback failed")) - ); - } - - /** - * to calculate the number of images reverted after previous quiz - * @param revertCountFetched count of deleted uploads - */ - private void setRevertParameter(int revertCountFetched) { - revertCount = revertCountFetched - revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0); - if (revertCount < 0){ - revertCount = 0; - revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0); - } - isRevertCountFetched = true; - } - - /** - * to check whether the criterion to call quiz is satisfied - */ - private void calculateRevertParameterAndShowQuiz(Activity activity) { - setUploadCount(); - setRevertCount(); - if ( revertCount < 0 || totalUploadCount < 0){ - revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0); - revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0); - return; - } - if (isRevertCountFetched && isUploadCountFetched && - totalUploadCount >= UPLOAD_COUNT_THRESHOLD && - (revertCount * 100) / totalUploadCount >= 50) { - callQuiz(activity); - } - } - - /** - * Alert which prompts to quiz - */ - @SuppressLint("StringFormatInvalid") - private void callQuiz(Activity activity) { - DialogUtil.showAlertDialog(activity, - activity.getString(R.string.quiz), - activity.getString(R.string.quiz_alert_message, REVERT_PERCENTAGE_FOR_MESSAGE), - activity.getString(R.string.about_translate_proceed), - activity.getString(android.R.string.cancel), - () -> startQuizActivity(activity), - null); - } - - private void startQuizActivity(Activity activity) { - int newRevetSharedPrefs = revertCount + revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0); - revertKvStore.putInt(REVERT_SHARED_PREFERENCE, newRevetSharedPrefs); - int newUploadCount = totalUploadCount + revertKvStore.getInt(UPLOAD_SHARED_PREFERENCE, 0); - revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, newUploadCount); - Intent i = new Intent(activity, WelcomeActivity.class); - i.putExtra("isQuiz", true); - activity.startActivity(i); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizChecker.kt b/app/src/main/java/fr/free/nrw/commons/quiz/QuizChecker.kt new file mode 100644 index 000000000..ec74ecf6f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/quiz/QuizChecker.kt @@ -0,0 +1,175 @@ +package fr.free.nrw.commons.quiz + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent + +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +import fr.free.nrw.commons.R +import fr.free.nrw.commons.WelcomeActivity +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.utils.DialogUtil +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber + + +/** + * Fetches the number of images uploaded and number of images reverted. + * Then it calculates the percentage of the images reverted. + * If the percentage of images reverted after the last quiz exceeds 50% and number of images uploaded is + * greater than 50, then the quiz is popped up. + */ +@Singleton +class QuizChecker @Inject constructor( + private val sessionManager: SessionManager, + private val okHttpJsonApiClient: OkHttpJsonApiClient, + @Named("default_preferences") private val revertKvStore: JsonKvStore +) { + + private var revertCount = 0 + private var totalUploadCount = 0 + private var isRevertCountFetched = false + private var isUploadCountFetched = false + + private val compositeDisposable = CompositeDisposable() + + private val UPLOAD_COUNT_THRESHOLD = 5 + private val REVERT_PERCENTAGE_FOR_MESSAGE = "50%" + private val REVERT_SHARED_PREFERENCE = "revertCount" + private val UPLOAD_SHARED_PREFERENCE = "uploadCount" + + /** + * Initializes quiz check by calculating revert parameters and showing quiz if necessary + */ + fun initQuizCheck(activity: Activity) { + calculateRevertParameterAndShowQuiz(activity) + } + + /** + * Clears disposables to avoid memory leaks + */ + fun cleanup() { + compositeDisposable.clear() + } + + /** + * Fetches the total number of images uploaded + */ + private fun setUploadCount() { + compositeDisposable.add( + okHttpJsonApiClient.getUploadCount(sessionManager.userName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { uploadCount -> setTotalUploadCount(uploadCount) }, + { t -> Timber.e(t, "Fetching upload count failed") } + ) + ) + } + + /** + * Sets the total upload count after subtracting stored preference + * @param uploadCount User's upload count + */ + private fun setTotalUploadCount(uploadCount: Int) { + totalUploadCount = uploadCount - revertKvStore.getInt( + UPLOAD_SHARED_PREFERENCE, + 0 + ) + if (totalUploadCount < 0) { + totalUploadCount = 0 + revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0) + } + isUploadCountFetched = true + } + + /** + * Fetches the revert count using the API + */ + private fun setRevertCount() { + compositeDisposable.add( + okHttpJsonApiClient.getAchievements(sessionManager.userName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + response?.let { setRevertParameter(it.deletedUploads) } + }, + { throwable -> Timber.e(throwable, "Fetching feedback failed") } + ) + ) + } + + /** + * Calculates the number of images reverted after the previous quiz + * @param revertCountFetched Count of deleted uploads + */ + private fun setRevertParameter(revertCountFetched: Int) { + revertCount = revertCountFetched - revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0) + if (revertCount < 0) { + revertCount = 0 + revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0) + } + isRevertCountFetched = true + } + + /** + * Checks whether the criteria for calling the quiz are satisfied + */ + private fun calculateRevertParameterAndShowQuiz(activity: Activity) { + setUploadCount() + setRevertCount() + + if (revertCount < 0 || totalUploadCount < 0) { + revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0) + revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0) + return + } + + if (isRevertCountFetched && isUploadCountFetched && + totalUploadCount >= UPLOAD_COUNT_THRESHOLD && + (revertCount * 100) / totalUploadCount >= 50 + ) { + callQuiz(activity) + } + } + + /** + * Displays an alert prompting the user to take the quiz + */ + @SuppressLint("StringFormatInvalid") + private fun callQuiz(activity: Activity) { + DialogUtil.showAlertDialog( + activity, + activity.getString(R.string.quiz), + activity.getString(R.string.quiz_alert_message, REVERT_PERCENTAGE_FOR_MESSAGE), + activity.getString(R.string.about_translate_proceed), + activity.getString(android.R.string.cancel), + { startQuizActivity(activity) }, + null + ) + } + + /** + * Starts the quiz activity and updates preferences for revert and upload counts + */ + private fun startQuizActivity(activity: Activity) { + val newRevertSharedPrefs = revertCount + revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0) + revertKvStore.putInt(REVERT_SHARED_PREFERENCE, newRevertSharedPrefs) + + val newUploadCount = totalUploadCount + revertKvStore.getInt(UPLOAD_SHARED_PREFERENCE, 0) + revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, newUploadCount) + + val intent = Intent(activity, WelcomeActivity::class.java).apply { + putExtra("isQuiz", true) + } + activity.startActivity(intent) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizController.java b/app/src/main/java/fr/free/nrw/commons/quiz/QuizController.java deleted file mode 100644 index a7b2c94ef..000000000 --- a/app/src/main/java/fr/free/nrw/commons/quiz/QuizController.java +++ /dev/null @@ -1,63 +0,0 @@ -package fr.free.nrw.commons.quiz; - -import android.content.Context; - -import java.util.ArrayList; - -import fr.free.nrw.commons.R; - -/** - * controls the quiz in the Activity - */ -public class QuizController { - - ArrayList quiz = new ArrayList<>(); - - private final String URL_FOR_SELFIE = "https://i.imgur.com/0fMYcpM.jpg"; - private final String URL_FOR_TAJ_MAHAL = "https://upload.wikimedia.org/wikipedia/commons/1/15/Taj_Mahal-03.jpg"; - private final String URL_FOR_BLURRY_IMAGE = "https://i.imgur.com/Kepb5jR.jpg"; - private final String URL_FOR_SCREENSHOT = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Social_media_app_mockup_screenshot.svg/500px-Social_media_app_mockup_screenshot.svg.png"; - private final String URL_FOR_EVENT = "https://upload.wikimedia.org/wikipedia/commons/5/51/HouseBuildingInNorthernVietnam.jpg"; - - public void initialize(Context context){ - QuizQuestion q1 = new QuizQuestion(1, - context.getString(R.string.quiz_question_string), - URL_FOR_SELFIE, - false, - context.getString(R.string.selfie_answer)); - quiz.add(q1); - - QuizQuestion q2 = new QuizQuestion(2, - context.getString(R.string.quiz_question_string), - URL_FOR_TAJ_MAHAL, - true, - context.getString(R.string.taj_mahal_answer)); - quiz.add(q2); - - QuizQuestion q3 = new QuizQuestion(3, - context.getString(R.string.quiz_question_string), - URL_FOR_BLURRY_IMAGE, - false, - context.getString(R.string.blurry_image_answer)); - quiz.add(q3); - - QuizQuestion q4 = new QuizQuestion(4, - context.getString(R.string.quiz_screenshot_question), - URL_FOR_SCREENSHOT, - false, - context.getString(R.string.screenshot_answer)); - quiz.add(q4); - - QuizQuestion q5 = new QuizQuestion(5, - context.getString(R.string.quiz_question_string), - URL_FOR_EVENT, - true, - context.getString(R.string.construction_event_answer)); - quiz.add(q5); - - } - - public ArrayList getQuiz() { - return quiz; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizController.kt b/app/src/main/java/fr/free/nrw/commons/quiz/QuizController.kt new file mode 100644 index 000000000..3cb4f52a6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/quiz/QuizController.kt @@ -0,0 +1,76 @@ +package fr.free.nrw.commons.quiz + +import android.content.Context + +import java.util.ArrayList + +import fr.free.nrw.commons.R + + +/** + * Controls the quiz in the Activity + */ +class QuizController { + + private val quiz: ArrayList = ArrayList() + + private val URL_FOR_SELFIE = "https://i.imgur.com/0fMYcpM.jpg" + private val URL_FOR_TAJ_MAHAL = "https://upload.wikimedia.org/wikipedia/commons/1/15/Taj_Mahal-03.jpg" + private val URL_FOR_BLURRY_IMAGE = "https://i.imgur.com/Kepb5jR.jpg" + private val URL_FOR_SCREENSHOT = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Social_media_app_mockup_screenshot.svg/500px-Social_media_app_mockup_screenshot.svg.png" + private val URL_FOR_EVENT = "https://upload.wikimedia.org/wikipedia/commons/5/51/HouseBuildingInNorthernVietnam.jpg" + + fun initialize(context: Context) { + val q1 = QuizQuestion( + 1, + context.getString(R.string.quiz_question_string), + URL_FOR_SELFIE, + false, + context.getString(R.string.selfie_answer) + ) + quiz.add(q1) + + val q2 = QuizQuestion( + 2, + context.getString(R.string.quiz_question_string), + URL_FOR_TAJ_MAHAL, + true, + context.getString(R.string.taj_mahal_answer) + ) + quiz.add(q2) + + val q3 = QuizQuestion( + 3, + context.getString(R.string.quiz_question_string), + URL_FOR_BLURRY_IMAGE, + false, + context.getString(R.string.blurry_image_answer) + ) + quiz.add(q3) + + val q4 = QuizQuestion( + 4, + context.getString(R.string.quiz_screenshot_question), + URL_FOR_SCREENSHOT, + false, + context.getString(R.string.screenshot_answer) + ) + quiz.add(q4) + + val q5 = QuizQuestion( + 5, + context.getString(R.string.quiz_question_string), + URL_FOR_EVENT, + true, + context.getString(R.string.construction_event_answer) + ) + quiz.add(q5) + } + + fun getQuiz(): ArrayList { + return quiz + } +} + + + diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.java b/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.java deleted file mode 100644 index ec6d1070d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.java +++ /dev/null @@ -1,188 +0,0 @@ -package fr.free.nrw.commons.quiz; - -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; - -import fr.free.nrw.commons.databinding.ActivityQuizResultBinding; -import java.io.File; -import java.io.FileOutputStream; - -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.contributions.MainActivity; - - -/** - * Displays the final score of quiz and congratulates the user - */ -public class QuizResultActivity extends AppCompatActivity { - - private ActivityQuizResultBinding binding; - private final int NUMBER_OF_QUESTIONS = 5; - private final int MULTIPLIER_TO_GET_PERCENTAGE = 20; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = ActivityQuizResultBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - setSupportActionBar(binding.toolbar.toolbar); - - binding.quizResultNext.setOnClickListener(view -> launchContributionActivity()); - - if ( getIntent() != null) { - Bundle extras = getIntent().getExtras(); - int score = extras.getInt("QuizResult"); - setScore(score); - }else{ - startActivityWithFlags( - this, MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP, - Intent.FLAG_ACTIVITY_SINGLE_TOP); - super.onBackPressed(); - } - } - - @Override - protected void onDestroy() { - binding = null; - super.onDestroy(); - } - - /** - * to calculate and display percentage and score - * @param score - */ - public void setScore(int score) { - final int scorePercent = score * MULTIPLIER_TO_GET_PERCENTAGE; - binding.resultProgressBar.setProgress(scorePercent); - binding.tvResultProgress.setText(score +" / " + NUMBER_OF_QUESTIONS); - final String message = getResources().getString(R.string.congratulatory_message_quiz,scorePercent + "%"); - binding.congratulatoryMessage.setText(message); - } - - /** - * to go to Contibutions Activity - */ - public void launchContributionActivity(){ - startActivityWithFlags( - this, MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP, - Intent.FLAG_ACTIVITY_SINGLE_TOP); - } - - @Override - public void onBackPressed() { - startActivityWithFlags( - this, MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP, - Intent.FLAG_ACTIVITY_SINGLE_TOP); - super.onBackPressed(); - } - - /** - * Function to call intent to an activity - * @param context - * @param cls - * @param flags - * @param - */ - public static void startActivityWithFlags(Context context, Class cls, int... flags) { - Intent intent = new Intent(context, cls); - for (int flag: flags) { - intent.addFlags(flag); - } - context.startActivity(intent); - } - - /** - * to inflate menu - * @param menu - * @return - */ - @Override - public boolean onCreateOptionsMenu(Menu menu) { - // Inflate the menu; this adds items to the action bar if it is present. - getMenuInflater().inflate(R.menu.menu_about, menu); - return true; - } - - /** - * if share option selected then take screenshot and launch alert - * @param item - * @return - */ - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int id = item.getItemId(); - if (id == R.id.share_app_icon) { - View rootView = getWindow().getDecorView().findViewById(android.R.id.content); - Bitmap screenShot = getScreenShot(rootView); - showAlert(screenShot); - } - - return super.onOptionsItemSelected(item); - } - - /** - * to store the screenshot of image in bitmap variable temporarily - * @param view - * @return - */ - public static Bitmap getScreenShot(View view) { - View screenView = view.getRootView(); - screenView.setDrawingCacheEnabled(true); - Bitmap bitmap = Bitmap.createBitmap(screenView.getDrawingCache()); - screenView.setDrawingCacheEnabled(false); - return bitmap; - } - - /** - * share the screenshot through social media - * @param bitmap - */ - void shareScreen(Bitmap bitmap) { - try { - File file = new File(this.getExternalCacheDir(),"screen.png"); - FileOutputStream fOut = new FileOutputStream(file); - bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut); - fOut.flush(); - fOut.close(); - file.setReadable(true, false); - final Intent intent = new Intent(android.content.Intent.ACTION_SEND); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file)); - intent.setType("image/png"); - startActivity(Intent.createChooser(intent, getString(R.string.share_image_via))); - } catch (Exception e) { - e.printStackTrace(); - } - } - - /** - * It display the alertDialog with Image of screenshot - * @param screenshot - */ - public void showAlert(Bitmap screenshot) { - AlertDialog.Builder alertadd = new AlertDialog.Builder(QuizResultActivity.this); - LayoutInflater factory = LayoutInflater.from(QuizResultActivity.this); - final View view = factory.inflate(R.layout.image_alert_layout, null); - ImageView screenShotImage = view.findViewById(R.id.alert_image); - screenShotImage.setImageBitmap(screenshot); - TextView shareMessage = view.findViewById(R.id.alert_text); - shareMessage.setText(R.string.quiz_result_share_message); - alertadd.setView(view); - alertadd.setPositiveButton(R.string.about_translate_proceed, (dialog, which) -> shareScreen(screenshot)); - alertadd.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel()); - alertadd.show(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.kt b/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.kt new file mode 100644 index 000000000..617f25a78 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.kt @@ -0,0 +1,193 @@ +package fr.free.nrw.commons.quiz + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ImageView +import android.widget.TextView + +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity + +import fr.free.nrw.commons.databinding.ActivityQuizResultBinding +import java.io.File +import java.io.FileOutputStream + +import fr.free.nrw.commons.R +import fr.free.nrw.commons.contributions.MainActivity + + +/** + * Displays the final score of quiz and congratulates the user + */ +class QuizResultActivity : AppCompatActivity() { + + private var binding: ActivityQuizResultBinding? = null + private val NUMBER_OF_QUESTIONS = 5 + private val MULTIPLIER_TO_GET_PERCENTAGE = 20 + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityQuizResultBinding.inflate(layoutInflater) + setContentView(binding?.root) + + setSupportActionBar(binding?.toolbar?.toolbar) + + binding?.quizResultNext?.setOnClickListener { + launchContributionActivity() + } + + intent?.extras?.let { extras -> + val score = extras.getInt("QuizResult", 0) + setScore(score) + } ?: run { + startActivityWithFlags( + this, MainActivity::class.java, + Intent.FLAG_ACTIVITY_CLEAR_TOP, Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + super.onBackPressed() + } + } + + override fun onDestroy() { + binding = null + super.onDestroy() + } + + /** + * To calculate and display percentage and score + * @param score + */ + @SuppressLint("StringFormatInvalid", "SetTextI18n") + fun setScore(score: Int) { + val scorePercent = score * MULTIPLIER_TO_GET_PERCENTAGE + binding?.resultProgressBar?.progress = scorePercent + binding?.tvResultProgress?.text = "$score / $NUMBER_OF_QUESTIONS" + val message = resources.getString(R.string.congratulatory_message_quiz, "$scorePercent%") + binding?.congratulatoryMessage?.text = message + } + + /** + * To go to Contributions Activity + */ + fun launchContributionActivity() { + startActivityWithFlags( + this, MainActivity::class.java, + Intent.FLAG_ACTIVITY_CLEAR_TOP, Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + } + + override fun onBackPressed() { + startActivityWithFlags( + this, MainActivity::class.java, + Intent.FLAG_ACTIVITY_CLEAR_TOP, Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + super.onBackPressed() + } + + /** + * Function to call intent to an activity + * @param context + * @param cls + * @param flags + */ + companion object { + fun startActivityWithFlags(context: Context, cls: Class, vararg flags: Int) { + val intent = Intent(context, cls) + flags.forEach { flag -> intent.addFlags(flag) } + context.startActivity(intent) + } + } + + /** + * To inflate menu + * @param menu + * @return + */ + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + // Inflate the menu; this adds items to the action bar if it is present. + menuInflater.inflate(R.menu.menu_about, menu) + return true + } + + /** + * If share option selected then take screenshot and launch alert + * @param item + * @return + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.share_app_icon) { + val rootView = window.decorView.findViewById(android.R.id.content) + val screenShot = getScreenShot(rootView) + showAlert(screenShot) + } + return super.onOptionsItemSelected(item) + } + + /** + * To store the screenshot of image in bitmap variable temporarily + * @param view + * @return + */ + fun getScreenShot(view: View): Bitmap { + val screenView = view.rootView + screenView.isDrawingCacheEnabled = true + val bitmap = Bitmap.createBitmap(screenView.drawingCache) + screenView.isDrawingCacheEnabled = false + return bitmap + } + + /** + * Share the screenshot through social media + * @param bitmap + */ + @SuppressLint("SetWorldReadable") + fun shareScreen(bitmap: Bitmap) { + try { + val file = File(this.externalCacheDir, "screen.png") + FileOutputStream(file).use { fOut -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut) + fOut.flush() + } + file.setReadable(true, false) + val intent = Intent(Intent.ACTION_SEND).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file)) + type = "image/png" + } + startActivity(Intent.createChooser(intent, getString(R.string.share_image_via))) + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * It displays the AlertDialog with Image of screenshot + * @param screenshot + */ + fun showAlert(screenshot: Bitmap) { + val alertadd = AlertDialog.Builder(this) + val factory = LayoutInflater.from(this) + val view = factory.inflate(R.layout.image_alert_layout, null) + val screenShotImage = view.findViewById(R.id.alert_image) + screenShotImage.setImageBitmap(screenshot) + val shareMessage = view.findViewById(R.id.alert_text) + shareMessage.setText(R.string.quiz_result_share_message) + alertadd.setView(view) + alertadd.setCancelable(false) + alertadd.setPositiveButton(R.string.about_translate_proceed) { dialog, _ -> + shareScreen(screenshot) + } + alertadd.setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.cancel() + } + alertadd.show() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/RadioGroupHelper.java b/app/src/main/java/fr/free/nrw/commons/quiz/RadioGroupHelper.java deleted file mode 100644 index 79756871d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/quiz/RadioGroupHelper.java +++ /dev/null @@ -1,64 +0,0 @@ -package fr.free.nrw.commons.quiz; - -import android.app.Activity; -import android.view.View; -import android.widget.CompoundButton; -import android.widget.RadioButton; - -import java.util.ArrayList; -import java.util.List; - -/** - * Used to group to or more radio buttons to ensure - * that at a particular time only one of them is selected - */ -public class RadioGroupHelper { - - public List radioButtons = new ArrayList<>(); - - /** - * Constructor to group radio buttons - * @param radios - */ - public RadioGroupHelper(RadioButton... radios) { - super(); - for (RadioButton rb : radios) { - add(rb); - } - } - - /** - * Constructor to group radio buttons - * @param activity - * @param radiosIDs - */ - public RadioGroupHelper(Activity activity, int... radiosIDs) { - this(activity.findViewById(android.R.id.content),radiosIDs); - } - - /** - * Constructor to group radio buttons - * @param rootView - * @param radiosIDs - */ - public RadioGroupHelper(View rootView, int... radiosIDs) { - super(); - for (int radioButtonID : radiosIDs) { - add(rootView.findViewById(radioButtonID)); - } - } - - private void add(CompoundButton button){ - this.radioButtons.add(button); - button.setOnClickListener(onClickListener); - } - - /** - * listener to ensure only one of the radio button is selected - */ - View.OnClickListener onClickListener = v -> { - for (CompoundButton rb : radioButtons) { - if (rb != v) rb.setChecked(false); - } - }; -} diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/RadioGroupHelper.kt b/app/src/main/java/fr/free/nrw/commons/quiz/RadioGroupHelper.kt new file mode 100644 index 000000000..8afdf94c5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/quiz/RadioGroupHelper.kt @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.quiz + +import android.app.Activity +import android.view.View +import android.widget.CompoundButton +import android.widget.RadioButton + +import java.util.ArrayList + +/** + * Used to group to or more radio buttons to ensure + * that at a particular time only one of them is selected + */ +class RadioGroupHelper { + + val radioButtons: MutableList = ArrayList() + + /** + * Constructor to group radio buttons + * @param radios + */ + constructor(vararg radios: RadioButton) { + for (rb in radios) { + add(rb) + } + } + + /** + * Constructor to group radio buttons + * @param activity + * @param radiosIDs + */ + constructor(activity: Activity, vararg radiosIDs: Int) : this( + *radiosIDs.map { id -> activity.findViewById(id) }.toTypedArray() + ) + + /** + * Constructor to group radio buttons + * @param rootView + * @param radiosIDs + */ + constructor(rootView: View, vararg radiosIDs: Int) { + for (radioButtonID in radiosIDs) { + add(rootView.findViewById(radioButtonID)) + } + } + + private fun add(button: CompoundButton) { + radioButtons.add(button) + button.setOnClickListener(onClickListener) + } + + /** + * listener to ensure only one of the radio button is selected + */ + private val onClickListener = View.OnClickListener { v -> + for (rb in radioButtons) { + if (rb != v) rb.isChecked = false + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesContentProvider.java b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesContentProvider.java deleted file mode 100644 index de94c4b09..000000000 --- a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesContentProvider.java +++ /dev/null @@ -1,122 +0,0 @@ -package fr.free.nrw.commons.recentlanguages; - -import static fr.free.nrw.commons.recentlanguages.RecentLanguagesDao.Table.COLUMN_NAME; -import static fr.free.nrw.commons.recentlanguages.RecentLanguagesDao.Table.TABLE_NAME; - -import android.content.ContentValues; -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 javax.inject.Inject; -import timber.log.Timber; - -/** - * Content provider of recently used languages - */ -public class RecentLanguagesContentProvider extends CommonsDaggerContentProvider { - - private static final String BASE_PATH = "recent_languages"; - public static final Uri BASE_URI = - Uri.parse("content://" + BuildConfig.RECENT_LANGUAGE_AUTHORITY + "/" + BASE_PATH); - - - /** - * Append language code to the base uri - * @param languageCode Code of a language - */ - public static Uri uriForCode(final String languageCode) { - return Uri.parse(BASE_URI + "/" + languageCode); - } - - @Inject - DBOpenHelper dbOpenHelper; - - @Override - public String getType(@NonNull final Uri uri) { - return null; - } - - /** - * Queries the SQLite database for the recently used languages - * @param uri : contains the uri for recently used languages - * @param projection : contains the all fields of the table - * @param selection : handles Where - * @param selectionArgs : the condition of Where clause - * @param sortOrder : ascending or descending - */ - @Override - public Cursor query(@NonNull final Uri uri, final String[] projection, final String selection, - final String[] selectionArgs, final String sortOrder) { - final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); - queryBuilder.setTables(TABLE_NAME); - final SQLiteDatabase db = dbOpenHelper.getReadableDatabase(); - final Cursor cursor = queryBuilder.query(db, projection, selection, - selectionArgs, null, null, sortOrder); - cursor.setNotificationUri(getContext().getContentResolver(), uri); - return cursor; - } - - /** - * Handles the update query of local SQLite Database - * @param uri : contains the uri for recently used languages - * @param contentValues : new values to be entered to db - * @param selection : handles Where - * @param selectionArgs : the condition of Where clause - */ - @Override - public int update(@NonNull final Uri uri, final ContentValues contentValues, - final String selection, final String[] selectionArgs) { - final SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); - final int rowsUpdated; - if (TextUtils.isEmpty(selection)) { - final int id = Integer.parseInt(uri.getLastPathSegment()); - rowsUpdated = sqlDB.update(TABLE_NAME, - contentValues, - COLUMN_NAME + " = ?", - new String[]{String.valueOf(id)}); - } else { - throw new IllegalArgumentException( - "Parameter `selection` should be empty when updating an ID"); - } - - getContext().getContentResolver().notifyChange(uri, null); - return rowsUpdated; - } - - /** - * Handles the insertion of new recently used languages record to local SQLite Database - * @param uri : contains the uri for recently used languages - * @param contentValues : new values to be entered to db - */ - @Override - public Uri insert(@NonNull final Uri uri, final ContentValues contentValues) { - final SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); - final long id = sqlDB.insert(TABLE_NAME, null, contentValues); - getContext().getContentResolver().notifyChange(uri, null); - return Uri.parse(BASE_URI + "/" + id); - } - - /** - * Handles the deletion of new recently used languages record to local SQLite Database - * @param uri : contains the uri for recently used languages - */ - @Override - public int delete(@NonNull final Uri uri, final String s, final String[] strings) { - final int rows; - final SQLiteDatabase db = dbOpenHelper.getReadableDatabase(); - Timber.d("Deleting recently used language %s", uri.getLastPathSegment()); - rows = db.delete( - TABLE_NAME, - "language_code = ?", - new String[]{uri.getLastPathSegment()} - ); - getContext().getContentResolver().notifyChange(uri, null); - return rows; - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesContentProvider.kt new file mode 100644 index 000000000..facc4384f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesContentProvider.kt @@ -0,0 +1,142 @@ +package fr.free.nrw.commons.recentlanguages + + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteQueryBuilder +import android.net.Uri +import android.text.TextUtils +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.data.DBOpenHelper +import fr.free.nrw.commons.di.CommonsDaggerContentProvider +import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao.Table.COLUMN_NAME +import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao.Table.TABLE_NAME +import javax.inject.Inject +import timber.log.Timber + + +/** + * Content provider of recently used languages + */ +class RecentLanguagesContentProvider : CommonsDaggerContentProvider() { + + companion object { + private const val BASE_PATH = "recent_languages" + val BASE_URI: Uri = + Uri.parse( + "content://${BuildConfig.RECENT_LANGUAGE_AUTHORITY}/$BASE_PATH" + ) + + /** + * Append language code to the base URI + * @param languageCode Code of a language + */ + @JvmStatic + fun uriForCode(languageCode: String): Uri { + return Uri.parse("$BASE_URI/$languageCode") + } + } + + @Inject + lateinit var dbOpenHelper: DBOpenHelper + + override fun getType(uri: Uri): String? { + return null + } + + /** + * Queries the SQLite database for the recently used languages + * @param uri : contains the URI for recently used languages + * @param projection : contains all fields of the table + * @param selection : handles WHERE + * @param selectionArgs : the condition of WHERE clause + * @param sortOrder : ascending or descending + */ + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? { + val queryBuilder = SQLiteQueryBuilder() + queryBuilder.tables = TABLE_NAME + val db = dbOpenHelper.readableDatabase + val cursor = queryBuilder.query( + db, + projection, + selection, + selectionArgs, + null, + null, + sortOrder + ) + cursor.setNotificationUri(context?.contentResolver, uri) + return cursor + } + + /** + * Handles the update query of local SQLite Database + * @param uri : contains the URI for recently used languages + * @param contentValues : new values to be entered to the database + * @param selection : handles WHERE + * @param selectionArgs : the condition of WHERE clause + */ + override fun update( + uri: Uri, + contentValues: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int { + val sqlDB = dbOpenHelper.writableDatabase + val rowsUpdated: Int + if (selection.isNullOrEmpty()) { + val id = uri.lastPathSegment?.toInt() + ?: throw IllegalArgumentException("Invalid URI: $uri") + rowsUpdated = sqlDB.update( + TABLE_NAME, + contentValues, + "$COLUMN_NAME = ?", + arrayOf(id.toString()) + ) + } else { + throw IllegalArgumentException("Parameter `selection` should be empty when updating an ID") + } + + context?.contentResolver?.notifyChange(uri, null) + return rowsUpdated + } + + /** + * Handles the insertion of new recently used languages record to local SQLite Database + * @param uri : contains the URI for recently used languages + * @param contentValues : new values to be entered to the database + */ + override fun insert(uri: Uri, contentValues: ContentValues?): Uri? { + val sqlDB = dbOpenHelper.writableDatabase + val id = sqlDB.insert( + TABLE_NAME, + null, + contentValues + ) + context?.contentResolver?.notifyChange(uri, null) + return Uri.parse("$BASE_URI/$id") + } + + /** + * Handles the deletion of a recently used languages record from local SQLite Database + * @param uri : contains the URI for recently used languages + */ + override fun delete(uri: Uri, s: String?, strings: Array?): Int { + val db = dbOpenHelper.readableDatabase + Timber.d("Deleting recently used language %s", uri.lastPathSegment) + val rows = db.delete( + TABLE_NAME, + "language_code = ?", + arrayOf(uri.lastPathSegment) + ) + context?.contentResolver?.notifyChange(uri, null) + return rows + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.java b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.java deleted file mode 100644 index c4a4bf518..000000000 --- a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.java +++ /dev/null @@ -1,202 +0,0 @@ -package fr.free.nrw.commons.recentlanguages; - -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 java.util.ArrayList; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Provider; -import javax.inject.Singleton; - -/** - * Handles database operations for recently used languages - */ -@Singleton -public class RecentLanguagesDao { - - private final Provider clientProvider; - - @Inject - public RecentLanguagesDao - (@Named("recent_languages") final Provider clientProvider) { - this.clientProvider = clientProvider; - } - - /** - * Find all persisted recently used languages on database - * @return list of recently used languages - */ - public List getRecentLanguages() { - final List languages = new ArrayList<>(); - final ContentProviderClient db = clientProvider.get(); - try (final Cursor cursor = db.query( - RecentLanguagesContentProvider.BASE_URI, - RecentLanguagesDao.Table.ALL_FIELDS, - null, - new String[]{}, - null)) { - if(cursor != null && cursor.moveToLast()) { - do { - languages.add(fromCursor(cursor)); - } while (cursor.moveToPrevious()); - } - } catch (final RemoteException e) { - throw new RuntimeException(e); - } finally { - db.release(); - } - return languages; - } - - /** - * Add a Language to database - * @param language : Language to add - */ - public void addRecentLanguage(final Language language) { - final ContentProviderClient db = clientProvider.get(); - try { - db.insert(RecentLanguagesContentProvider.BASE_URI, toContentValues(language)); - } catch (final RemoteException e) { - throw new RuntimeException(e); - } finally { - db.release(); - } - } - - /** - * Delete a language from database - * @param languageCode : code of the Language to delete - */ - public void deleteRecentLanguage(final String languageCode) { - final ContentProviderClient db = clientProvider.get(); - try { - db.delete(RecentLanguagesContentProvider.uriForCode(languageCode), null, null); - } catch (final RemoteException e) { - throw new RuntimeException(e); - } finally { - db.release(); - } - } - - /** - * Find a language from database based on its name - * @param languageCode : code of the Language to find - * @return boolean : is language in database ? - */ - public boolean findRecentLanguage(final String languageCode) { - if (languageCode == null) { //Avoiding NPE's - return false; - } - final ContentProviderClient db = clientProvider.get(); - try (final Cursor cursor = db.query( - RecentLanguagesContentProvider.BASE_URI, - RecentLanguagesDao.Table.ALL_FIELDS, - Table.COLUMN_CODE + "=?", - new String[]{languageCode}, - null - )) { - if (cursor != null && cursor.moveToFirst()) { - return true; - } - } catch (final RemoteException e) { - throw new RuntimeException(e); - } finally { - db.release(); - } - return false; - } - - /** - * It creates an Recent Language object from data stored in the SQLite DB by using cursor - * @param cursor cursor - * @return Language object - */ - @NonNull - Language fromCursor(final Cursor cursor) { - // Hardcoding column positions! - final String languageName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)); - final String languageCode = cursor.getString(cursor.getColumnIndex(Table.COLUMN_CODE)); - return new Language(languageName, languageCode); - } - - /** - * Takes data from Language and create a content value object - * @param recentLanguage recently used language - * @return ContentValues - */ - private ContentValues toContentValues(final Language recentLanguage) { - final ContentValues cv = new ContentValues(); - cv.put(Table.COLUMN_NAME, recentLanguage.getLanguageName()); - cv.put(Table.COLUMN_CODE, recentLanguage.getLanguageCode()); - return cv; - } - - /** - * This class contains the database table architecture for recently used languages, - * It also contains queries and logic necessary to the create, update, delete this table. - */ - public static final class Table { - public static final String TABLE_NAME = "recent_languages"; - static final String COLUMN_NAME = "language_name"; - static final String COLUMN_CODE = "language_code"; - - // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. - public static final String[] ALL_FIELDS = { - COLUMN_NAME, - COLUMN_CODE - }; - - static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME; - - static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " (" - + COLUMN_NAME + " STRING," - + COLUMN_CODE + " STRING PRIMARY KEY" - + ");"; - - /** - * This method creates a LanguagesTable in SQLiteDatabase - * @param db SQLiteDatabase - */ - public static void onCreate(final SQLiteDatabase db) { - db.execSQL(CREATE_TABLE_STATEMENT); - } - - /** - * This method deletes LanguagesTable from SQLiteDatabase - * @param db SQLiteDatabase - */ - public static void onDelete(final SQLiteDatabase db) { - db.execSQL(DROP_TABLE_STATEMENT); - onCreate(db); - } - - /** - * This method is called on migrating from a older version to a newer version - * @param db SQLiteDatabase - * @param from Version from which we are migrating - * @param to Version to which we are migrating - */ - public static void onUpdate(final SQLiteDatabase db, int from, final int to) { - if (from == to) { - return; - } - if (from < 19) { - // doesn't exist yet - from++; - onUpdate(db, from, to); - return; - } - if (from == 19) { - // table added in version 20 - onCreate(db); - from++; - onUpdate(db, from, to); - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.kt b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.kt new file mode 100644 index 000000000..a4a06185b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesDao.kt @@ -0,0 +1,215 @@ +package fr.free.nrw.commons.recentlanguages + +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 javax.inject.Inject +import javax.inject.Named +import javax.inject.Provider +import javax.inject.Singleton + + +/** + * Handles database operations for recently used languages + */ +@Singleton +class RecentLanguagesDao @Inject constructor( + @Named("recent_languages") + private val clientProvider: Provider +) { + + /** + * Find all persisted recently used languages on database + * @return list of recently used languages + */ + fun getRecentLanguages(): List { + val languages = mutableListOf() + val db = clientProvider.get() + try { + db.query( + RecentLanguagesContentProvider.BASE_URI, + Table.ALL_FIELDS, + null, + arrayOf(), + null + )?.use { cursor -> + if (cursor.moveToLast()) { + do { + languages.add(fromCursor(cursor)) + } while (cursor.moveToPrevious()) + } + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + return languages + } + + /** + * Add a Language to database + * @param language : Language to add + */ + fun addRecentLanguage(language: Language) { + val db = clientProvider.get() + try { + db.insert( + RecentLanguagesContentProvider.BASE_URI, + toContentValues(language) + ) + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + } + + /** + * Delete a language from database + * @param languageCode : code of the Language to delete + */ + fun deleteRecentLanguage(languageCode: String) { + val db = clientProvider.get() + try { + db.delete( + RecentLanguagesContentProvider.uriForCode(languageCode), + null, + null + ) + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + } + + /** + * Find a language from database based on its name + * @param languageCode : code of the Language to find + * @return boolean : is language in database ? + */ + fun findRecentLanguage(languageCode: String?): Boolean { + if (languageCode == null) { // Avoiding NPEs + return false + } + val db = clientProvider.get() + try { + db.query( + RecentLanguagesContentProvider.BASE_URI, + Table.ALL_FIELDS, + "${Table.COLUMN_CODE}=?", + arrayOf(languageCode), + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + return true + } + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + return false + } + + /** + * It creates an Recent Language object from data stored in the SQLite DB by using cursor + * @param cursor cursor + * @return Language object + */ + @SuppressLint("Range") + fun fromCursor(cursor: Cursor): Language { + // Hardcoding column positions! + val languageName = cursor.getString( + cursor.getColumnIndex(Table.COLUMN_NAME) + ) + val languageCode = cursor.getString( + cursor.getColumnIndex(Table.COLUMN_CODE) + ) + return Language(languageName, languageCode) + } + + /** + * Takes data from Language and create a content value object + * @param recentLanguage recently used language + * @return ContentValues + */ + private fun toContentValues(recentLanguage: Language): ContentValues { + return ContentValues().apply { + put(Table.COLUMN_NAME, recentLanguage.languageName) + put(Table.COLUMN_CODE, recentLanguage.languageCode) + } + } + + /** + * This class contains the database table architecture for recently used languages, + * It also contains queries and logic necessary to the create, update, delete this table. + */ + object Table { + const val TABLE_NAME = "recent_languages" + const val COLUMN_NAME = "language_name" + const val COLUMN_CODE = "language_code" + + // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. + @JvmStatic + val ALL_FIELDS = arrayOf( + COLUMN_NAME, + COLUMN_CODE + ) + + const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME" + + const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" + + "$COLUMN_NAME STRING," + + "$COLUMN_CODE STRING PRIMARY KEY" + + ");" + + /** + * This method creates a LanguagesTable in SQLiteDatabase + * @param db SQLiteDatabase + */ + @SuppressLint("SQLiteString") + @JvmStatic + fun onCreate(db: SQLiteDatabase) { + db.execSQL(CREATE_TABLE_STATEMENT) + } + + /** + * This method deletes LanguagesTable from SQLiteDatabase + * @param db SQLiteDatabase + */ + @JvmStatic + fun onDelete(db: SQLiteDatabase) { + db.execSQL(DROP_TABLE_STATEMENT) + onCreate(db) + } + + /** + * This method is called on migrating from a older version to a newer version + * @param db SQLiteDatabase + * @param from Version from which we are migrating + * @param to Version to which we are migrating + */ + @JvmStatic + fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) { + if (from == to) { + return + } + if (from < 19) { + // doesn't exist yet + onUpdate(db, from + 1, to) + return + } + if (from == 19) { + // table added in version 20 + onCreate(db) + onUpdate(db, from + 1, to) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java deleted file mode 100644 index de0154947..000000000 --- a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java +++ /dev/null @@ -1,423 +0,0 @@ -package fr.free.nrw.commons.repository; - -import androidx.annotation.Nullable; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.category.CategoriesModel; -import fr.free.nrw.commons.category.CategoryItem; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.contributions.ContributionDao; -import fr.free.nrw.commons.filepicker.UploadableFile; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.nearby.NearbyPlaces; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.upload.ImageCoordinates; -import fr.free.nrw.commons.upload.SimilarImageInterface; -import fr.free.nrw.commons.upload.UploadController; -import fr.free.nrw.commons.upload.UploadItem; -import fr.free.nrw.commons.upload.UploadModel; -import fr.free.nrw.commons.upload.structure.depictions.DepictModel; -import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; -import io.reactivex.Flowable; -import io.reactivex.Observable; -import io.reactivex.Single; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import javax.inject.Inject; -import javax.inject.Singleton; -import timber.log.Timber; - -/** - * The repository class for UploadActivity - */ -@Singleton -public class UploadRepository { - - private final UploadModel uploadModel; - private final UploadController uploadController; - private final CategoriesModel categoriesModel; - private final NearbyPlaces nearbyPlaces; - private final DepictModel depictModel; - - private static final double NEARBY_RADIUS_IN_KILO_METERS = 0.1; //100 meters - private final ContributionDao contributionDao; - - @Inject - public UploadRepository(UploadModel uploadModel, - UploadController uploadController, - CategoriesModel categoriesModel, - NearbyPlaces nearbyPlaces, - DepictModel depictModel, - ContributionDao contributionDao) { - this.uploadModel = uploadModel; - this.uploadController = uploadController; - this.categoriesModel = categoriesModel; - this.nearbyPlaces = nearbyPlaces; - this.depictModel = depictModel; - this.contributionDao=contributionDao; - } - - /** - * asks the RemoteDataSource to build contributions - * - * @return - */ - public Observable buildContributions() { - return uploadModel.buildContributions(); - } - - /** - * asks the RemoteDataSource to start upload for the contribution - * - * @param contribution - */ - - public void prepareMedia(Contribution contribution) { - uploadController.prepareMedia(contribution); - } - - - public void saveContribution(Contribution contribution) { - contributionDao.save(contribution).blockingAwait(); - } - - /** - * Fetches and returns all the Upload Items - * - * @return - */ - public List getUploads() { - return uploadModel.getUploads(); - } - - /** - *Prepare for a fresh upload - */ - public void cleanup() { - uploadModel.cleanUp(); - //This needs further refactoring, this should not be here, right now the structure wont suppoort rhis - categoriesModel.cleanUp(); - depictModel.cleanUp(); - } - - /** - * Fetches and returns the selected categories for the current upload - * - * @return - */ - public List getSelectedCategories() { - return categoriesModel.getSelectedCategories(); - } - - /** - * all categories from MWApi - * - * @param query - * @param imageTitleList - * @param selectedDepictions - * @return - */ - public Observable> searchAll(String query, List imageTitleList, - List selectedDepictions) { - return categoriesModel.searchAll(query, imageTitleList, selectedDepictions); - } - - /** - * sets the list of selected categories for the current upload - * - * @param categoryStringList - */ - public void setSelectedCategories(List categoryStringList) { - uploadModel.setSelectedCategories(categoryStringList); - } - - /** - * handles the category selection/deselection - * - * @param categoryItem - */ - public void onCategoryClicked(CategoryItem categoryItem, final Media media) { - categoriesModel.onCategoryItemClicked(categoryItem, media); - } - - /** - * prunes the category list for irrelevant categories see #750 - * - * @param name - * @return - */ - public boolean isSpammyCategory(String name) { - return categoriesModel.isSpammyCategory(name); - } - - /** - * retursn the string list of available license from the LocalDataSource - * - * @return - */ - public List getLicenses() { - return uploadModel.getLicenses(); - } - - /** - * returns the selected license for the current upload - * - * @return - */ - public String getSelectedLicense() { - return uploadModel.getSelectedLicense(); - } - - /** - * returns the number of Upload Items - * - * @return - */ - public int getCount() { - return uploadModel.getCount(); - } - - /** - * ask the RemoteDataSource to pre process the image - * - * @param uploadableFile - * @param place - * @param similarImageInterface - * @return - */ - public Observable preProcessImage(UploadableFile uploadableFile, Place place, - SimilarImageInterface similarImageInterface, LatLng inAppPictureLocation) { - return uploadModel.preProcessImage(uploadableFile, place, - similarImageInterface, inAppPictureLocation); - } - - /** - * query the RemoteDataSource for image quality - * - * @param uploadItem UploadItem whose caption is to be checked - * @return Quality of UploadItem - */ - public Single getImageQuality(UploadItem uploadItem, LatLng location) { - return uploadModel.getImageQuality(uploadItem, location); - } - - /** - * query the RemoteDataSource for image duplicity check - * - * @param filePath file to be checked - * @return IMAGE_DUPLICATE or IMAGE_OK - */ - public Single checkDuplicateImage(String filePath) { - return uploadModel.checkDuplicateImage(filePath); - } - - /** - * query the RemoteDataSource for caption quality - * - * @param uploadItem UploadItem whose caption is to be checked - * @return Quality of caption of the UploadItem - */ - public Single getCaptionQuality(UploadItem uploadItem) { - return uploadModel.getCaptionQuality(uploadItem); - } - - /** - * asks the LocalDataSource to delete the file with the given file path - * - * @param filePath - */ - public void deletePicture(String filePath) { - uploadModel.deletePicture(filePath); - } - - /** - * fetches and returns the upload item - * - * @param index - * @return - */ - public UploadItem getUploadItem(int index) { - if (index >= 0) { - return uploadModel.getItems().get(index); - } - return null; //There is no item to copy details - } - - /** - * set selected license for the current upload - * - * @param licenseName - */ - public void setSelectedLicense(String licenseName) { - uploadModel.setSelectedLicense(licenseName); - } - - public void onDepictItemClicked(DepictedItem depictedItem, final Media media) { - uploadModel.onDepictItemClicked(depictedItem, media); - } - - /** - * Fetches and returns the selected depictions for the current upload - * - * @return - */ - - public List getSelectedDepictions() { - return uploadModel.getSelectedDepictions(); - } - - /** - * Provides selected existing depicts - * - * @return selected existing depicts - */ - public List getSelectedExistingDepictions() { - return uploadModel.getSelectedExistingDepictions(); - } - - /** - * Initialize existing depicts - * - * @param selectedExistingDepictions existing depicts - */ - public void setSelectedExistingDepictions(final List selectedExistingDepictions) { - uploadModel.setSelectedExistingDepictions(selectedExistingDepictions); - } - /** - * Search all depictions from - * - * @param query - * @return - */ - - public Flowable> searchAllEntities(String query) { - return depictModel.searchAllEntities(query, this); - } - - /** - * Gets the depiction for each unique {@link Place} associated with an {@link UploadItem} - * from {@link #getUploads()} - * - * @return a single that provides the depictions - */ - public Single> getPlaceDepictions() { - final Set qids = new HashSet<>(); - for (final UploadItem item : getUploads()) { - final Place place = item.getPlace(); - if (place != null) { - qids.add(place.getWikiDataEntityId()); - } - } - return depictModel.getPlaceDepictions(new ArrayList<>(qids)); - } - - /** - * Gets the category for each unique {@link Place} associated with an {@link UploadItem} - * from {@link #getUploads()} - * - * @return a single that provides the categories - */ - public Single> getPlaceCategories() { - final Set qids = new HashSet<>(); - for (final UploadItem item : getUploads()) { - final Place place = item.getPlace(); - if (place != null) { - qids.add(place.getCategory()); - } - } - return Single.fromObservable(categoriesModel.getCategoriesByName(new ArrayList<>(qids))); - } - - /** - * Takes depict IDs as a parameter, converts into a slash separated String and Gets DepictItem - * from the server - * - * @param depictionsQIDs IDs of Depiction - * @return Flowable> - */ - public Flowable> getDepictions(final List depictionsQIDs){ - final String ids = joinQIDs(depictionsQIDs); - return depictModel.getDepictions(ids).toFlowable(); - } - - /** - * Builds a string by joining all IDs divided by "|" - * - * @param depictionsQIDs IDs of depiction ex. ["Q11023","Q1356"] - * @return string ex. "Q11023|Q1356" - */ - private String joinQIDs(final List depictionsQIDs) { - if (depictionsQIDs != null && !depictionsQIDs.isEmpty()) { - final StringBuilder buffer = new StringBuilder(depictionsQIDs.get(0)); - - if (depictionsQIDs.size() > 1) { - for (int i = 1; i < depictionsQIDs.size(); i++) { - buffer.append("|"); - buffer.append(depictionsQIDs.get(i)); - } - } - return buffer.toString(); - } - return null; - } - - /** - * Returns nearest place matching the passed latitude and longitude - * - * @param decLatitude - * @param decLongitude - * @return - */ - @Nullable - public Place checkNearbyPlaces(final double decLatitude, final double decLongitude) { - try { - final List fromWikidataQuery = nearbyPlaces.getFromWikidataQuery(new LatLng( - decLatitude, decLongitude, 0.0f), - Locale.getDefault().getLanguage(), - NEARBY_RADIUS_IN_KILO_METERS, null); - return (fromWikidataQuery != null && fromWikidataQuery.size() > 0) ? fromWikidataQuery - .get(0) : null; - } catch (final Exception e) { - Timber.e("Error fetching nearby places: %s", e.getMessage()); - return null; - } - } - - public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex) { - uploadModel.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex); - } - - public boolean isWMLSupportedForThisPlace() { - return uploadModel.getItems().get(0).isWLMUpload(); - } - - /** - * Provides selected existing categories - * - * @return selected existing categories - */ - public List getSelectedExistingCategories() { - return categoriesModel.getSelectedExistingCategories(); - } - - /** - * Initialize existing categories - * - * @param selectedExistingCategories existing categories - */ - public void setSelectedExistingCategories(final List selectedExistingCategories) { - categoriesModel.setSelectedExistingCategories(selectedExistingCategories); - } - - /** - * Takes category names and Gets CategoryItem from the server - * - * @param categories names of Category - * @return Observable> - */ - public Observable> getCategories(final List categories){ - return categoriesModel.getCategoriesByName(categories); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt new file mode 100644 index 000000000..377953254 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt @@ -0,0 +1,410 @@ +package fr.free.nrw.commons.repository + +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.category.CategoriesModel +import fr.free.nrw.commons.category.CategoryItem +import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.contributions.ContributionDao +import fr.free.nrw.commons.filepicker.UploadableFile +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.nearby.NearbyPlaces +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.upload.ImageCoordinates +import fr.free.nrw.commons.upload.SimilarImageInterface +import fr.free.nrw.commons.upload.UploadController +import fr.free.nrw.commons.upload.UploadItem +import fr.free.nrw.commons.upload.UploadModel +import fr.free.nrw.commons.upload.structure.depictions.DepictModel +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem +import io.reactivex.Flowable +import io.reactivex.Observable +import io.reactivex.Single +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The repository class for UploadActivity + */ +@Singleton +class UploadRepository @Inject constructor( + private val uploadModel: UploadModel, + private val uploadController: UploadController, + private val categoriesModel: CategoriesModel, + private val nearbyPlaces: NearbyPlaces, + private val depictModel: DepictModel, + private val contributionDao: ContributionDao +) { + + companion object { + private const val NEARBY_RADIUS_IN_KILO_METERS = 0.1 // 100 meters + } + + /** + * Asks the RemoteDataSource to build contributions + * + * @return + */ + fun buildContributions(): Observable? { + return uploadModel.buildContributions() + } + + /** + * Asks the RemoteDataSource to start upload for the contribution + * + * @param contribution + */ + fun prepareMedia(contribution: Contribution) { + uploadController.prepareMedia(contribution) + } + + fun saveContribution(contribution: Contribution) { + contributionDao.save(contribution).blockingAwait() + } + + /** + * Fetches and returns all the Upload Items + * + * @return + */ + fun getUploads(): List { + return uploadModel.getUploads() + } + + /** + * Prepare for a fresh upload + */ + fun cleanup() { + uploadModel.cleanUp() + // This needs further refactoring, this should not be here, right now the structure + // won't support this + categoriesModel.cleanUp() + depictModel.cleanUp() + } + + /** + * Fetches and returns the selected categories for the current upload + * + * @return + */ + fun getSelectedCategories(): List { + return categoriesModel.getSelectedCategories() + } + + /** + * All categories from MWApi + * + * @param query + * @param imageTitleList + * @param selectedDepictions + * @return + */ + fun searchAll( + query: String, + imageTitleList: List, + selectedDepictions: List + ): Observable> { + return categoriesModel.searchAll(query, imageTitleList, selectedDepictions) + } + + /** + * Sets the list of selected categories for the current upload + * + * @param categoryStringList + */ + fun setSelectedCategories(categoryStringList: List) { + uploadModel.setSelectedCategories(categoryStringList) + } + + /** + * Handles the category selection/deselection + * + * @param categoryItem + */ + fun onCategoryClicked(categoryItem: CategoryItem, media: Media?) { + categoriesModel.onCategoryItemClicked(categoryItem, media) + } + + /** + * Prunes the category list for irrelevant categories see #750 + * + * @param name + * @return + */ + fun isSpammyCategory(name: String): Boolean { + return categoriesModel.isSpammyCategory(name) + } + + /** + * Returns the string list of available licenses from the LocalDataSource + * + * @return + */ + fun getLicenses(): List { + return uploadModel.licenses + } + + /** + * Returns the selected license for the current upload + * + * @return + */ + fun getSelectedLicense(): String? { + return uploadModel.selectedLicense + } + + /** + * Returns the number of Upload Items + * + * @return + */ + fun getCount(): Int { + return uploadModel.count + } + + /** + * Ask the RemoteDataSource to preprocess the image + * + * @param uploadableFile + * @param place + * @param similarImageInterface + * @param inAppPictureLocation + * @return + */ + fun preProcessImage( + uploadableFile: UploadableFile?, + place: Place?, + similarImageInterface: SimilarImageInterface?, + inAppPictureLocation: LatLng? + ): Observable? { + return uploadModel.preProcessImage( + uploadableFile, + place, + similarImageInterface, + inAppPictureLocation + ) + } + + /** + * Query the RemoteDataSource for image quality + * + * @param uploadItem UploadItem whose caption is to be checked + * @param location Location of the image + * @return Quality of UploadItem + */ + fun getImageQuality(uploadItem: UploadItem, location: LatLng?): Single? { + return uploadModel.getImageQuality(uploadItem, location) + } + + /** + * Query the RemoteDataSource for image duplicity check + * + * @param filePath file to be checked + * @return IMAGE_DUPLICATE or IMAGE_OK + */ + fun checkDuplicateImage(filePath: String): Single { + return uploadModel.checkDuplicateImage(filePath) + } + + /** + * query the RemoteDataSource for caption quality + * + * @param uploadItem UploadItem whose caption is to be checked + * @return Quality of caption of the UploadItem + */ + fun getCaptionQuality(uploadItem: UploadItem): Single? { + return uploadModel.getCaptionQuality(uploadItem) + } + + /** + * asks the LocalDataSource to delete the file with the given file path + * + * @param filePath + */ + fun deletePicture(filePath: String) { + uploadModel.deletePicture(filePath) + } + + /** + * fetches and returns the upload item + * + * @param index + * @return + */ + fun getUploadItem(index: Int): UploadItem? { + return if (index >= 0) { + uploadModel.items.getOrNull(index) + } else null //There is no item to copy details + } + + /** + * set selected license for the current upload + * + * @param licenseName + */ + fun setSelectedLicense(licenseName: String) { + uploadModel.selectedLicense = licenseName + } + + fun onDepictItemClicked(depictedItem: DepictedItem, media: Media?) { + uploadModel.onDepictItemClicked(depictedItem, media) + } + + /** + * Fetches and returns the selected depictions for the current upload + * + * @return + */ + fun getSelectedDepictions(): List { + return uploadModel.selectedDepictions + } + + /** + * Provides selected existing depicts + * + * @return selected existing depicts + */ + fun getSelectedExistingDepictions(): List { + return uploadModel.selectedExistingDepictions + } + + /** + * Initialize existing depicts + * + * @param selectedExistingDepictions existing depicts + */ + fun setSelectedExistingDepictions(selectedExistingDepictions: List) { + uploadModel.selectedExistingDepictions = selectedExistingDepictions + } + + /** + * Search all depictions from + * + * @param query + * @return + */ + fun searchAllEntities(query: String): Flowable> { + return depictModel.searchAllEntities(query, this) + } + + /** + * Gets the depiction for each unique {@link Place} associated with an {@link UploadItem} + * from {@link #getUploads()} + * + * @return a single that provides the depictions + */ + fun getPlaceDepictions(): Single> { + val qids = mutableSetOf() + getUploads().forEach { item -> + item.place?.let { + it.wikiDataEntityId?.let { it1 -> + qids.add(it1) + } + } + } + return depictModel.getPlaceDepictions(qids.toList()) + } + + /** + * Gets the category for each unique {@link Place} associated with an {@link UploadItem} + * from {@link #getUploads()} + * + * @return a single that provides the categories + */ + fun getPlaceCategories(): Single> { + val qids = mutableSetOf() + getUploads().forEach { item -> + item.place?.category?.let { qids.add(it) } + } + return Single.fromObservable(categoriesModel.getCategoriesByName(qids.toList())) + } + + /** + * Takes depict IDs as a parameter, converts into a slash separated String and Gets DepictItem + * from the server + * + * @param depictionsQIDs IDs of Depiction + * @return Flowable> + */ + fun getDepictions(depictionsQIDs: List): Flowable> { + val ids = joinQIDs(depictionsQIDs) ?: "" + return depictModel.getDepictions(ids).toFlowable() + } + + /** + * Builds a string by joining all IDs divided by "|" + * + * @param depictionsQIDs IDs of depiction ex. ["Q11023","Q1356"] + * @return string ex. "Q11023|Q1356" + */ + private fun joinQIDs(depictionsQIDs: List?): String? { + return depictionsQIDs?.takeIf { + it.isNotEmpty() + }?.joinToString("|") + } + + /** + * Returns nearest place matching the passed latitude and longitude + * + * @param decLatitude + * @param decLongitude + * @return + */ + fun checkNearbyPlaces(decLatitude: Double, decLongitude: Double): Place? { + return try { + val fromWikidataQuery = nearbyPlaces.getFromWikidataQuery( + LatLng(decLatitude, decLongitude, 0.0f), + Locale.getDefault().language, + NEARBY_RADIUS_IN_KILO_METERS, + null + ) + fromWikidataQuery?.firstOrNull() + } catch (e: Exception) { + Timber.e("Error fetching nearby places: %s", e.message) + null + } + } + + fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates, uploadItemIndex: Int) { + uploadModel.useSimilarPictureCoordinates( + imageCoordinates, + uploadItemIndex + ) + } + + fun isWMLSupportedForThisPlace(): Boolean { + return uploadModel.items.firstOrNull()?.isWLMUpload == true + } + + /** + * Provides selected existing categories + * + * @return selected existing categories + */ + fun getSelectedExistingCategories(): List { + return categoriesModel.getSelectedExistingCategories() + } + + /** + * Initialize existing categories + * + * @param selectedExistingCategories existing categories + */ + fun setSelectedExistingCategories(selectedExistingCategories: List) { + categoriesModel.setSelectedExistingCategories( + selectedExistingCategories.toMutableList() + ) + } + + /** + * Takes category names and Gets CategoryItem from the server + * + * @param categories names of Category + * @return Observable> + */ + fun getCategories(categories: List): Observable> { + return categoriesModel.getCategoriesByName(categories) + ?.map { it.toList() } ?: Observable.empty() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.java deleted file mode 100644 index 5eb758ada..000000000 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.java +++ /dev/null @@ -1,333 +0,0 @@ -package fr.free.nrw.commons.review; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.graphics.PorterDuff; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.MotionEvent; -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.auth.AccountUtil; -import fr.free.nrw.commons.databinding.ActivityReviewBinding; -import fr.free.nrw.commons.delete.DeleteHelper; -import fr.free.nrw.commons.media.MediaDetailFragment; -import fr.free.nrw.commons.theme.BaseActivity; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.ViewUtil; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import javax.inject.Inject; - -public class ReviewActivity extends BaseActivity { - - - private ActivityReviewBinding binding; - - MediaDetailFragment mediaDetailFragment; - public ReviewPagerAdapter reviewPagerAdapter; - public ReviewController reviewController; - @Inject - ReviewHelper reviewHelper; - @Inject - DeleteHelper deleteHelper; - /** - * Represent fragment for ReviewImage - * Use to call some methods of ReviewImage fragment - */ - private ReviewImageFragment reviewImageFragment; - - /** - * Flag to check whether there are any non-hidden categories in the File - */ - private boolean hasNonHiddenCategories = false; - - final String SAVED_MEDIA = "saved_media"; - private Media media; - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - if (media != null) { - outState.putParcelable(SAVED_MEDIA, media); - } - } - - /** - * Consumers should be simply using this method to use this activity. - * - * @param context - * @param title Page title - */ - public static void startYourself(Context context, String title) { - Intent reviewActivity = new Intent(context, ReviewActivity.class); - reviewActivity.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); - reviewActivity.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); - context.startActivity(reviewActivity); - } - - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - - public Media getMedia() { - return media; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = ActivityReviewBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - setSupportActionBar(binding.toolbarBinding.toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - reviewController = new ReviewController(deleteHelper, this); - - reviewPagerAdapter = new ReviewPagerAdapter(getSupportFragmentManager()); - binding.viewPagerReview.setAdapter(reviewPagerAdapter); - binding.pagerIndicatorReview.setViewPager(binding.viewPagerReview); - binding.pbReviewImage.setVisibility(View.VISIBLE); - - Drawable d[]=binding.skipImage.getCompoundDrawablesRelative(); - d[2].setColorFilter(getApplicationContext().getResources().getColor(R.color.button_blue), PorterDuff.Mode.SRC_IN); - - if (savedInstanceState != null && savedInstanceState.getParcelable(SAVED_MEDIA) != null) { - updateImage(savedInstanceState.getParcelable(SAVED_MEDIA)); // Use existing media if we have one - setUpMediaDetailOnOrientation(); - } else { - runRandomizer(); //Run randomizer whenever everything is ready so that a first random image will be added - } - - binding.skipImage.setOnClickListener(view -> { - reviewImageFragment = getInstanceOfReviewImageFragment(); - reviewImageFragment.disableButtons(); - runRandomizer(); - }); - - binding.reviewImageView.setOnClickListener(view ->setUpMediaDetailFragment()); - - binding.skipImage.setOnTouchListener((view, event) -> { - if (event.getAction() == MotionEvent.ACTION_UP && event.getRawX() >= ( - binding.skipImage.getRight() - binding.skipImage - .getCompoundDrawables()[2].getBounds().width())) { - showSkipImageInfo(); - return true; - } - return false; - }); - } - - @Override - public boolean onSupportNavigateUp() { - onBackPressed(); - return true; - } - - @SuppressLint("CheckResult") - public boolean runRandomizer() { - hasNonHiddenCategories = false; - binding.pbReviewImage.setVisibility(View.VISIBLE); - binding.viewPagerReview.setCurrentItem(0); - // Finds non-hidden categories from Media instance - compositeDisposable.add(reviewHelper.getRandomMedia() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::checkWhetherFileIsUsedInWikis)); - return true; - } - - /** - * Check whether media is used or not in any Wiki Page - */ - @SuppressLint("CheckResult") - private void checkWhetherFileIsUsedInWikis(final Media media) { - compositeDisposable.add(reviewHelper.checkFileUsage(media.getFilename()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - // result false indicates media is not used in any wiki - if (!result) { - // Finds non-hidden categories from Media instance - findNonHiddenCategories(media); - } else { - runRandomizer(); - } - })); - } - - /** - * Finds non-hidden categories and updates current image - */ - private void findNonHiddenCategories(Media media) { - for(String key : media.getCategoriesHiddenStatus().keySet()) { - Boolean value = media.getCategoriesHiddenStatus().get(key); - // If non-hidden category is found then set hasNonHiddenCategories to true - // so that category review cannot be skipped - if(!value) { - hasNonHiddenCategories = true; - break; - } - } - reviewImageFragment = getInstanceOfReviewImageFragment(); - reviewImageFragment.disableButtons(); - updateImage(media); - } - - @SuppressLint("CheckResult") - private void updateImage(Media media) { - reviewHelper.addViewedImagesToDB(media.getPageId()); - this.media = media; - String fileName = media.getFilename(); - if (fileName.length() == 0) { - ViewUtil.showShortSnackbar(binding.drawerLayout, R.string.error_review); - return; - } - - //If The Media User and Current Session Username is same then Skip the Image - if (media.getUser() != null && media.getUser().equals(AccountUtil.getUserName(getApplicationContext()))) { - runRandomizer(); - return; - } - - binding.reviewImageView.setImageURI(media.getImageUrl()); - - reviewController.onImageRefreshed(media); //file name is updated - compositeDisposable.add(reviewHelper.getFirstRevisionOfFile(fileName) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(revision -> { - reviewController.firstRevision = revision; - reviewPagerAdapter.updateFileInformation(); - @SuppressLint({"StringFormatInvalid", "LocalSuppress"}) String caption = String.format(getString(R.string.review_is_uploaded_by), fileName, revision.getUser()); - binding.tvImageCaption.setText(caption); - binding.pbReviewImage.setVisibility(View.GONE); - reviewImageFragment = getInstanceOfReviewImageFragment(); - reviewImageFragment.enableButtons(); - })); - binding.viewPagerReview.setCurrentItem(0); - } - - public void swipeToNext() { - int nextPos = binding.viewPagerReview.getCurrentItem() + 1; - // If currently at category fragment, then check whether the media has any non-hidden category - if (nextPos <= 3) { - binding.viewPagerReview.setCurrentItem(nextPos); - if (nextPos == 2) { - // The media has no non-hidden category. Such media are already flagged by server-side bots, so no need to review manually. - if (!hasNonHiddenCategories) { - swipeToNext(); - return; - } - } - } else { - runRandomizer(); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - compositeDisposable.clear(); - binding = null; - } - - public void showSkipImageInfo(){ - DialogUtil.showAlertDialog(ReviewActivity.this, - getString(R.string.skip_image).toUpperCase(), - getString(R.string.skip_image_explanation), - getString(android.R.string.ok), - "", - null, - null); - } - - public void showReviewImageInfo() { - DialogUtil.showAlertDialog(ReviewActivity.this, - getString(R.string.title_activity_review), - getString(R.string.review_image_explanation), - getString(android.R.string.ok), - "", - null, - null); - } - - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.menu_review_activty, menu); - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_image_info: - showReviewImageInfo(); - return true; - } - return super.onOptionsItemSelected(item); - } - - /** - * this function return the instance of reviewImageFragment - */ - public ReviewImageFragment getInstanceOfReviewImageFragment(){ - int currentItemOfReviewPager = binding.viewPagerReview.getCurrentItem(); - reviewImageFragment = (ReviewImageFragment) reviewPagerAdapter.instantiateItem(binding.viewPagerReview, currentItemOfReviewPager); - return reviewImageFragment; - } - - /** - * set up the media detail fragment when click on the review image - */ - private void setUpMediaDetailFragment() { - if (binding.mediaDetailContainer.getVisibility() == View.GONE && media != null) { - binding.mediaDetailContainer.setVisibility(View.VISIBLE); - binding.reviewActivityContainer.setVisibility(View.INVISIBLE); - FragmentManager fragmentManager = getSupportFragmentManager(); - mediaDetailFragment = new MediaDetailFragment(); - Bundle bundle = new Bundle(); - bundle.putParcelable("media", media); - mediaDetailFragment.setArguments(bundle); - fragmentManager.beginTransaction().add(R.id.mediaDetailContainer, mediaDetailFragment). - addToBackStack("MediaDetail").commit(); - } - } - - /** - * handle the back pressed event of this activity - * this function call every time when back button is pressed - */ - @Override - public void onBackPressed() { - if (binding.mediaDetailContainer.getVisibility() == View.VISIBLE) { - binding.mediaDetailContainer.setVisibility(View.GONE); - binding.reviewActivityContainer.setVisibility(View.VISIBLE); - } - super.onBackPressed(); - } - - /** - * set up media detail fragment after orientation change - */ - private void setUpMediaDetailOnOrientation() { - Fragment mediaDetailFragment = getSupportFragmentManager() - .findFragmentById(R.id.mediaDetailContainer); - if (mediaDetailFragment != null) { - binding.mediaDetailContainer.setVisibility(View.VISIBLE); - binding.reviewActivityContainer.setVisibility(View.INVISIBLE); - getSupportFragmentManager().beginTransaction() - .replace(R.id.mediaDetailContainer, mediaDetailFragment).commit(); - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt new file mode 100644 index 000000000..40eb24ed0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewActivity.kt @@ -0,0 +1,336 @@ +package fr.free.nrw.commons.review + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.PorterDuff +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.getUserName +import fr.free.nrw.commons.databinding.ActivityReviewBinding +import fr.free.nrw.commons.delete.DeleteHelper +import fr.free.nrw.commons.media.MediaDetailFragment +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.DialogUtil +import fr.free.nrw.commons.utils.ViewUtil +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import java.util.Locale +import javax.inject.Inject + +class ReviewActivity : BaseActivity() { + + private lateinit var binding: ActivityReviewBinding + + private var mediaDetailFragment: MediaDetailFragment? = null + lateinit var reviewPagerAdapter: ReviewPagerAdapter + lateinit var reviewController: ReviewController + + @Inject + lateinit var reviewHelper: ReviewHelper + + @Inject + lateinit var deleteHelper: DeleteHelper + + /** + * Represent fragment for ReviewImage + * Use to call some methods of ReviewImage fragment + */ + private var reviewImageFragment: ReviewImageFragment? = null + private var hasNonHiddenCategories = false + var media: Media? = null + + private val SAVED_MEDIA = "saved_media" + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + media?.let { + outState.putParcelable(SAVED_MEDIA, it) + } + } + + /** + * Consumers should be simply using this method to use this activity. + * + * @param context + * @param title Page title + */ + companion object { + fun startYourself(context: Context, title: String) { + val reviewActivity = Intent(context, ReviewActivity::class.java) + reviewActivity.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + reviewActivity.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + context.startActivity(reviewActivity) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityReviewBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbarBinding?.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + reviewController = ReviewController(deleteHelper, this) + + reviewPagerAdapter = ReviewPagerAdapter(supportFragmentManager) + binding.viewPagerReview.adapter = reviewPagerAdapter + binding.pagerIndicatorReview.setViewPager(binding.viewPagerReview) + binding.pbReviewImage.visibility = View.VISIBLE + + binding.skipImage.compoundDrawablesRelative[2]?.setColorFilter( + resources.getColor(R.color.button_blue), + PorterDuff.Mode.SRC_IN + ) + + if (savedInstanceState?.getParcelable(SAVED_MEDIA) != null) { + updateImage(savedInstanceState.getParcelable(SAVED_MEDIA)!!) + setUpMediaDetailOnOrientation() + } else { + runRandomizer() + } + + binding.skipImage.setOnClickListener { + reviewImageFragment = getInstanceOfReviewImageFragment() + reviewImageFragment?.disableButtons() + runRandomizer() + } + + binding.reviewImageView.setOnClickListener { + setUpMediaDetailFragment() + } + + binding.skipImage.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_UP && + event.rawX >= (binding.skipImage.right - binding.skipImage.compoundDrawables[2].bounds.width()) + ) { + showSkipImageInfo() + true + } else { + false + } + } + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + + @SuppressLint("CheckResult") + fun runRandomizer(): Boolean { + hasNonHiddenCategories = false + binding.pbReviewImage.visibility = View.VISIBLE + binding.viewPagerReview.currentItem = 0 + + compositeDisposable.add( + reviewHelper.getRandomMedia() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(::checkWhetherFileIsUsedInWikis) + ) + return true + } + + /** + * Check whether media is used or not in any Wiki Page + */ + @SuppressLint("CheckResult") + private fun checkWhetherFileIsUsedInWikis(media: Media) { + compositeDisposable.add( + reviewHelper.checkFileUsage(media.filename) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { result -> + if (!result) { + findNonHiddenCategories(media) + } else { + runRandomizer() + } + } + ) + } + + /** + * Finds non-hidden categories and updates current image + */ + private fun findNonHiddenCategories(media: Media) { + this.media = media + // If non-hidden category is found then set hasNonHiddenCategories to true + // so that category review cannot be skipped + hasNonHiddenCategories = media.categoriesHiddenStatus.values.any { !it } + reviewImageFragment = getInstanceOfReviewImageFragment() + reviewImageFragment?.disableButtons() + updateImage(media) + } + + @SuppressLint("CheckResult") + private fun updateImage(media: Media) { + reviewHelper.addViewedImagesToDB(media.pageId) + this.media = media + val fileName = media.filename + + if (fileName.isNullOrEmpty()) { + ViewUtil.showShortSnackbar(binding.drawerLayout, R.string.error_review) + return + } + + //If The Media User and Current Session Username is same then Skip the Image + if (media.user == getUserName(applicationContext)) { + runRandomizer() + return + } + + binding.reviewImageView.setImageURI(media.imageUrl) + + reviewController.onImageRefreshed(media) // filename is updated + compositeDisposable.add( + reviewHelper.getFirstRevisionOfFile(fileName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { revision -> + reviewController.firstRevision = revision + reviewPagerAdapter.updateFileInformation() + val caption = getString( + R.string.review_is_uploaded_by, + fileName, + revision.user() + ) + binding.tvImageCaption.text = caption + binding.pbReviewImage.visibility = View.GONE + reviewImageFragment = getInstanceOfReviewImageFragment() + reviewImageFragment?.enableButtons() + } + ) + binding.viewPagerReview.currentItem = 0 + } + + fun swipeToNext() { + val nextPos = binding.viewPagerReview.currentItem + 1 + + // If currently at category fragment, then check whether the media has any non-hidden category + if (nextPos <= 3) { + binding.viewPagerReview.currentItem = nextPos + if (nextPos == 2 && !hasNonHiddenCategories) + { + // The media has no non-hidden category. Such media are already flagged by server-side bots, so no need to review manually. + swipeToNext() + } + } else { + runRandomizer() + } + } + + public override fun onDestroy() { + super.onDestroy() + compositeDisposable.clear() + } + + fun showSkipImageInfo() { + DialogUtil.showAlertDialog( + this, + getString(R.string.skip_image).uppercase(Locale.ROOT), + getString(R.string.skip_image_explanation), + getString(android.R.string.ok), + null, + null, + null + ) + } + + fun showReviewImageInfo() { + DialogUtil.showAlertDialog( + this, + getString(R.string.title_activity_review), + getString(R.string.review_image_explanation), + getString(android.R.string.ok), + null, + null, + null + ) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_review_activty, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_image_info -> { + showReviewImageInfo() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + /** + * this function return the instance of reviewImageFragment + */ + private fun getInstanceOfReviewImageFragment(): ReviewImageFragment? { + val currentItemOfReviewPager = binding.viewPagerReview.currentItem + return reviewPagerAdapter.instantiateItem( + binding.viewPagerReview, + currentItemOfReviewPager + ) as? ReviewImageFragment + } + + /** + * set up the media detail fragment when click on the review image + */ + private fun setUpMediaDetailFragment() { + if (binding.mediaDetailContainer.visibility == View.GONE && media != null) { + binding.mediaDetailContainer.visibility = View.VISIBLE + binding.reviewActivityContainer.visibility = View.INVISIBLE + val fragmentManager = supportFragmentManager + mediaDetailFragment = MediaDetailFragment().apply { + arguments = Bundle().apply { + putParcelable("media", media) + } + } + fragmentManager.beginTransaction() + .add(R.id.mediaDetailContainer, mediaDetailFragment!!) + .addToBackStack("MediaDetail") + .commit() + } + } + + /** + * handle the back pressed event of this activity + * this function call every time when back button is pressed + */ + @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 (binding.mediaDetailContainer.visibility == View.VISIBLE) { + binding.mediaDetailContainer.visibility = View.GONE + binding.reviewActivityContainer.visibility = View.VISIBLE + } + super.onBackPressed() + } + + /** + * set up media detail fragment after orientation change + */ + private fun setUpMediaDetailOnOrientation() { + val fragment = supportFragmentManager.findFragmentById(R.id.mediaDetailContainer) + fragment?.let { + binding.mediaDetailContainer.visibility = View.VISIBLE + binding.reviewActivityContainer.visibility = View.INVISIBLE + supportFragmentManager.beginTransaction() + .replace(R.id.mediaDetailContainer, it) + .commit() + } + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewController.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewController.java deleted file mode 100644 index e3d5b2256..000000000 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewController.java +++ /dev/null @@ -1,220 +0,0 @@ -package fr.free.nrw.commons.review; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.NotificationManager; -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException; -import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage; - -import java.util.ArrayList; -import java.util.concurrent.Callable; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.actions.PageEditClient; -import fr.free.nrw.commons.actions.ThanksClient; -import fr.free.nrw.commons.delete.DeleteHelper; -import fr.free.nrw.commons.di.ApplicationlessInjection; -import fr.free.nrw.commons.utils.ViewUtil; -import io.reactivex.Observable; -import io.reactivex.ObservableSource; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; -import timber.log.Timber; - -@Singleton -public class ReviewController { - private static final int NOTIFICATION_SEND_THANK = 0x102; - private static final int NOTIFICATION_CHECK_CATEGORY = 0x101; - protected static ArrayList categories; - @Inject - ThanksClient thanksClient; - - @Inject - SessionManager sessionManager; - private final DeleteHelper deleteHelper; - @Nullable - MwQueryPage.Revision firstRevision; // TODO: maybe we can expand this class to include fileName - @Inject - @Named("commons-page-edit") - PageEditClient pageEditClient; - private NotificationManager notificationManager; - private NotificationCompat.Builder notificationBuilder; - private Media media; - - ReviewController(DeleteHelper deleteHelper, Context context) { - this.deleteHelper = deleteHelper; - CommonsApplication.createNotificationChannel(context.getApplicationContext()); - notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - notificationBuilder = new NotificationCompat.Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL); - } - - void onImageRefreshed(Media media) { - this.media = media; - } - - public Media getMedia() { - return media; - } - - public enum DeleteReason { - SPAM, - COPYRIGHT_VIOLATION - } - - void reportSpam(@NonNull Activity activity, ReviewCallback reviewCallback) { - Timber.d("Report spam for %s", media.getFilename()); - deleteHelper.askReasonAndExecute(media, - activity, - activity.getResources().getString(R.string.review_spam_report_question), - DeleteReason.SPAM, - reviewCallback); - } - - void reportPossibleCopyRightViolation(@NonNull Activity activity, ReviewCallback reviewCallback) { - Timber.d("Report spam for %s", media.getFilename()); - deleteHelper.askReasonAndExecute(media, - activity, - activity.getResources().getString(R.string.review_c_violation_report_question), - DeleteReason.COPYRIGHT_VIOLATION, - reviewCallback); - } - - @SuppressLint("CheckResult") - void reportWrongCategory(@NonNull Activity activity, ReviewCallback reviewCallback) { - Context context = activity.getApplicationContext(); - ApplicationlessInjection - .getInstance(context) - .getCommonsApplicationComponent() - .inject(this); - - ViewUtil.showShortToast(context, context.getString(R.string.check_category_toast, media.getDisplayTitle())); - - publishProgress(context, 0); - String summary = context.getString(R.string.check_category_edit_summary); - Observable.defer((Callable>) () -> - pageEditClient.appendEdit(media.getFilename(), "\n{{subst:chc}}\n", summary)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe((result) -> { - publishProgress(context, 2); - String message; - String title; - - if (result) { - title = context.getString(R.string.check_category_success_title); - message = context.getString(R.string.check_category_success_message, media.getDisplayTitle()); - reviewCallback.onSuccess(); - } else { - title = context.getString(R.string.check_category_failure_title); - message = context.getString(R.string.check_category_failure_message, media.getDisplayTitle()); - reviewCallback.onFailure(); - } - - showNotification(title, message); - - }, Timber::e); - } - - private void publishProgress(@NonNull Context context, int i) { - int[] messages = new int[]{R.string.getting_edit_token, R.string.check_category_adding_template}; - String message = ""; - if (0 < i && i < messages.length) { - message = context.getString(messages[i]); - } - - notificationBuilder.setContentTitle(context.getString(R.string.check_category_notification_title, media.getDisplayTitle())) - .setStyle(new NotificationCompat.BigTextStyle() - .bigText(message)) - .setSmallIcon(R.drawable.ic_launcher) - .setProgress(messages.length, i, false) - .setOngoing(true); - notificationManager.notify(NOTIFICATION_CHECK_CATEGORY, notificationBuilder.build()); - } - - @SuppressLint({"CheckResult", "StringFormatInvalid"}) - void sendThanks(@NonNull Activity activity) { - Context context = activity.getApplicationContext(); - ApplicationlessInjection - .getInstance(context) - .getCommonsApplicationComponent() - .inject(this); - ViewUtil.showShortToast(context, context.getString(R.string.send_thank_toast, media.getDisplayTitle())); - - if (firstRevision == null) { - return; - } - - Observable.defer((Callable>) () -> thanksClient.thank(firstRevision.getRevisionId())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - displayThanksToast(context, result); - }, throwable -> { - if (throwable instanceof InvalidLoginTokenException) { - final String username = sessionManager.getUserName(); - final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener( - activity, - activity.getString(R.string.invalid_login_message), - username - ); - - CommonsApplication.getInstance().clearApplicationData( - activity, logoutListener); - } else { - Timber.e(throwable); - } - }); - } - - @SuppressLint("StringFormatInvalid") - private void displayThanksToast(final Context context, final boolean result){ - final String message; - final String title; - if (result) { - title = context.getString(R.string.send_thank_success_title); - message = context.getString(R.string.send_thank_success_message, media.getDisplayTitle()); - } else { - title = context.getString(R.string.send_thank_failure_title); - message = context.getString(R.string.send_thank_failure_message, media.getDisplayTitle()); - } - - ViewUtil.showShortToast(context,message); - } - - private void showNotification(String title, String message) { - notificationBuilder.setDefaults(NotificationCompat.DEFAULT_ALL) - .setContentTitle(title) - .setStyle(new NotificationCompat.BigTextStyle() - .bigText(message)) - .setSmallIcon(R.drawable.ic_launcher) - .setProgress(0, 0, false) - .setOngoing(false) - .setPriority(NotificationCompat.PRIORITY_HIGH); - notificationManager.notify(NOTIFICATION_SEND_THANK, notificationBuilder.build()); - } - - public interface ReviewCallback { - void onSuccess(); - - void onFailure(); - - void onTokenException(Exception e); - - void disableButtons(); - - void enableButtons(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewController.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewController.kt new file mode 100644 index 000000000..2450dd850 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewController.kt @@ -0,0 +1,229 @@ +package fr.free.nrw.commons.review + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.NotificationManager +import android.content.Context + +import androidx.core.app.NotificationCompat + +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException +import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage + +import java.util.ArrayList + +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.actions.PageEditClient +import fr.free.nrw.commons.actions.ThanksClient +import fr.free.nrw.commons.delete.DeleteHelper +import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.utils.ViewUtil +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import timber.log.Timber + + +@Singleton +class ReviewController @Inject constructor( + private val deleteHelper: DeleteHelper, + context: Context +) { + + companion object { + private const val NOTIFICATION_SEND_THANK = 0x102 + private const val NOTIFICATION_CHECK_CATEGORY = 0x101 + protected var categories: ArrayList = ArrayList() + } + + @Inject + lateinit var thanksClient: ThanksClient + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + @field: Named("commons-page-edit") + lateinit var pageEditClient: PageEditClient + + var firstRevision: MwQueryPage.Revision? = null // TODO: maybe we can expand this class to include fileName + + private val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationBuilder: NotificationCompat.Builder = + NotificationCompat.Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL) + + var media: Media? = null + + init { + CommonsApplication.createNotificationChannel(context.applicationContext) + } + + fun onImageRefreshed(media: Media) { + this.media = media + } + + enum class DeleteReason { + SPAM, + COPYRIGHT_VIOLATION + } + + fun reportSpam(activity: Activity, reviewCallback: ReviewCallback) { + Timber.d("Report spam for %s", media?.filename) + deleteHelper.askReasonAndExecute( + media, + activity, + activity.resources.getString(R.string.review_spam_report_question), + DeleteReason.SPAM, + reviewCallback + ) + } + + fun reportPossibleCopyRightViolation(activity: Activity, reviewCallback: ReviewCallback) { + Timber.d("Report copyright violation for %s", media?.filename) + deleteHelper.askReasonAndExecute( + media, + activity, + activity.resources.getString(R.string.review_c_violation_report_question), + DeleteReason.COPYRIGHT_VIOLATION, + reviewCallback + ) + } + + @SuppressLint("CheckResult") + fun reportWrongCategory(activity: Activity, reviewCallback: ReviewCallback) { + val context = activity.applicationContext + ApplicationlessInjection + .getInstance(context) + .commonsApplicationComponent + .inject(this) + + ViewUtil.showShortToast( + context, + context.getString(R.string.check_category_toast, media?.displayTitle) + ) + + publishProgress(context, 0) + val summary = context.getString(R.string.check_category_edit_summary) + + Observable.defer { + pageEditClient.appendEdit(media?.filename ?: "", "\n{{subst:chc}}\n", summary) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ result -> + publishProgress(context, 2) + val (title, message) = if (result) { + reviewCallback.onSuccess() + context.getString(R.string.check_category_success_title) to + context.getString(R.string.check_category_success_message, media?.displayTitle) + } else { + reviewCallback.onFailure() + context.getString(R.string.check_category_failure_title) to + context.getString(R.string.check_category_failure_message, media?.displayTitle) + } + showNotification(title, message) + }, Timber::e) + } + + private fun publishProgress(context: Context, progress: Int) { + val messages = arrayOf( + R.string.getting_edit_token, + R.string.check_category_adding_template + ) + + val message = if (progress in 1 until messages.size) { + context.getString(messages[progress]) + } else "" + + notificationBuilder.setContentTitle( + context.getString( + R.string.check_category_notification_title, + media?.displayTitle + ) + ) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setSmallIcon(R.drawable.ic_launcher) + .setProgress(messages.size, progress, false) + .setOngoing(true) + + notificationManager.notify(NOTIFICATION_CHECK_CATEGORY, notificationBuilder.build()) + } + + @SuppressLint("CheckResult") + fun sendThanks(activity: Activity) { + val context = activity.applicationContext + ApplicationlessInjection + .getInstance(context) + .commonsApplicationComponent + .inject(this) + + ViewUtil.showShortToast( + context, + context.getString(R.string.send_thank_toast, media?.displayTitle) + ) + + if (firstRevision == null) return + + Observable.defer { + thanksClient.thank(firstRevision!!.revisionId()) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ result -> + displayThanksToast(context, result) + }, { throwable -> + if (throwable is InvalidLoginTokenException) { + val username = sessionManager.userName + val logoutListener = CommonsApplication.BaseLogoutListener( + activity, + activity.getString(R.string.invalid_login_message), + username + ) + CommonsApplication.instance.clearApplicationData(activity, logoutListener) + } else { + Timber.e(throwable) + } + }) + } + + @SuppressLint("StringFormatInvalid") + private fun displayThanksToast(context: Context, result: Boolean) { + val (title, message) = if (result) { + context.getString(R.string.send_thank_success_title) to + context.getString(R.string.send_thank_success_message, media?.displayTitle) + } else { + context.getString(R.string.send_thank_failure_title) to + context.getString(R.string.send_thank_failure_message, media?.displayTitle) + } + + ViewUtil.showShortToast(context, message) + } + + private fun showNotification(title: String, message: String) { + notificationBuilder.setDefaults(NotificationCompat.DEFAULT_ALL) + .setContentTitle(title) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setSmallIcon(R.drawable.ic_launcher) + .setProgress(0, 0, false) + .setOngoing(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + + notificationManager.notify(NOTIFICATION_SEND_THANK, notificationBuilder.build()) + } + + interface ReviewCallback { + fun onSuccess() + fun onFailure() + fun onTokenException(e: Exception) + fun disableButtons() + fun enableButtons() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewDao.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewDao.kt similarity index 54% rename from app/src/main/java/fr/free/nrw/commons/review/ReviewDao.java rename to app/src/main/java/fr/free/nrw/commons/review/ReviewDao.kt index c3e8c90a8..1dc9b6ae8 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewDao.java +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewDao.kt @@ -1,15 +1,15 @@ -package fr.free.nrw.commons.review; +package fr.free.nrw.commons.review -import androidx.room.Dao; -import androidx.room.Insert; -import androidx.room.OnConflictStrategy; -import androidx.room.Query; +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query /** * Dao interface for reviewed images database */ @Dao -public interface ReviewDao { +interface ReviewDao { /** * Inserts reviewed/skipped image identifier into the database @@ -17,7 +17,7 @@ public interface ReviewDao { * @param reviewEntity */ @Insert(onConflict = OnConflictStrategy.IGNORE) - void insert(ReviewEntity reviewEntity); + fun insert(reviewEntity: ReviewEntity) /** * Checks if the image has already been reviewed/skipped by the user @@ -26,7 +26,6 @@ public interface ReviewDao { * @param imageId * @return */ - @Query( "SELECT EXISTS (SELECT * from `reviewed-images` where imageId = (:imageId))") - Boolean isReviewedAlready(String imageId); - -} \ No newline at end of file + @Query("SELECT EXISTS (SELECT * from `reviewed-images` where imageId = (:imageId))") + fun isReviewedAlready(imageId: String): Boolean +} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewEntity.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewEntity.java deleted file mode 100644 index 071111b15..000000000 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewEntity.java +++ /dev/null @@ -1,19 +0,0 @@ -package fr.free.nrw.commons.review; - -import androidx.annotation.NonNull; -import androidx.room.Entity; -import androidx.room.PrimaryKey; - -/** - * Entity to store reviewed/skipped images identifier - */ -@Entity(tableName = "reviewed-images") -public class ReviewEntity { - @PrimaryKey - @NonNull - String imageId; - - public ReviewEntity(String imageId) { - this.imageId = imageId; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewEntity.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewEntity.kt new file mode 100644 index 000000000..473c143c7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewEntity.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.review + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity to store reviewed/skipped images identifier + */ +@Entity(tableName = "reviewed-images") +data class ReviewEntity( + @PrimaryKey + val imageId: String +) diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.kt index 8a77c11ed..3ad15d8bf 100644 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewHelper.kt @@ -34,7 +34,7 @@ class ReviewHelper reviewInterface .getRecentChanges() .map { it.query()?.pages() } - .map(MutableList::shuffled) + .map { it.shuffled() } .flatMapIterable { changes: List? -> changes } .filter { isChangeReviewable(it) } @@ -77,7 +77,7 @@ class ReviewHelper * @param image * @return */ - fun getReviewStatus(image: String?): Boolean = dao?.isReviewedAlready(image) ?: false + fun getReviewStatus(image: String?): Boolean = image?.let { dao?.isReviewedAlready(it) } ?: false /** * Gets the first revision of the file from filename @@ -132,7 +132,7 @@ class ReviewHelper */ fun addViewedImagesToDB(imageId: String?) { Completable - .fromAction { dao!!.insert(ReviewEntity(imageId)) } + .fromAction { imageId?.let { ReviewEntity(it) }?.let { dao!!.insert(it) } } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.java deleted file mode 100644 index 7e0cd0ee3..000000000 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.java +++ /dev/null @@ -1,262 +0,0 @@ -package fr.free.nrw.commons.review; - -import android.graphics.Color; -import android.os.Bundle; -import android.text.Html; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import androidx.annotation.NonNull; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException; -import fr.free.nrw.commons.databinding.FragmentReviewImageBinding; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import java.util.ArrayList; -import java.util.List; -import javax.inject.Inject; - -public class ReviewImageFragment extends CommonsDaggerSupportFragment { - - static final int CATEGORY = 2; - private static final int SPAM = 0; - private static final int COPYRIGHT = 1; - private static final int THANKS = 3; - - private int position; - - private FragmentReviewImageBinding binding; - - @Inject - SessionManager sessionManager; - - - // Constant variable used to store user's key name for onSaveInstanceState method - private final String SAVED_USER = "saved_user"; - - // Variable that stores the value of user - private String user; - - public void update(final int position) { - this.position = position; - } - - private String updateCategoriesQuestion() { - final Media media = getReviewActivity().getMedia(); - if (media != null && media.getCategoriesHiddenStatus() != null && isAdded()) { - // Filter category name attribute from all categories - final List categories = new ArrayList<>(); - for(final String key : media.getCategoriesHiddenStatus().keySet()) { - String value = String.valueOf(key); - // Each category returned has a format like "Category:" - // so remove the prefix "Category:" - final int index = key.indexOf("Category:"); - if(index == 0) { - value = key.substring(9); - } - categories.add(value); - } - String catString = TextUtils.join(", ", categories); - if (catString != null && !catString.equals("") && binding.tvReviewQuestionContext != null) { - catString = "" + catString + ""; - final String stringToConvertHtml = String.format(getResources().getString(R.string.review_category_explanation), catString); - return Html.fromHtml(stringToConvertHtml).toString(); - } - } - return getResources().getString(R.string.review_no_category); - } - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - @Override - public View onCreateView(final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - position = getArguments().getInt("position"); - binding = FragmentReviewImageBinding.inflate(inflater, container, false); - - final String question; - String explanation=null; - String yesButtonText; - final String noButtonText; - - binding.buttonYes.setOnClickListener(view -> onYesButtonClicked()); - - switch (position) { - case SPAM: - question = getString(R.string.review_spam); - explanation = getString(R.string.review_spam_explanation); - yesButtonText = getString(R.string.yes); - noButtonText = getString(R.string.no); - binding.buttonNo.setOnClickListener(view -> getReviewActivity() - .reviewController.reportSpam(requireActivity(), getReviewCallback())); - break; - case COPYRIGHT: - enableButtons(); - question = getString(R.string.review_copyright); - explanation = getString(R.string.review_copyright_explanation); - yesButtonText = getString(R.string.yes); - noButtonText = getString(R.string.no); - binding.buttonNo.setOnClickListener(view -> getReviewActivity() - .reviewController - .reportPossibleCopyRightViolation(requireActivity(), getReviewCallback())); - break; - case CATEGORY: - enableButtons(); - question = getString(R.string.review_category); - explanation = updateCategoriesQuestion(); - yesButtonText = getString(R.string.yes); - noButtonText = getString(R.string.no); - binding.buttonNo.setOnClickListener(view -> { - getReviewActivity() - .reviewController - .reportWrongCategory(requireActivity(), getReviewCallback()); - getReviewActivity().swipeToNext(); - }); - break; - case THANKS: - enableButtons(); - question = getString(R.string.review_thanks); - - if (getReviewActivity().reviewController.firstRevision != null) { - user = getReviewActivity().reviewController.firstRevision.getUser(); - } else { - if(savedInstanceState != null) { - user = savedInstanceState.getString(SAVED_USER); - } - } - - //if the user is null because of whatsoever reason, review will not be sent anyways - if (!TextUtils.isEmpty(user)) { - explanation = getString(R.string.review_thanks_explanation, user); - } - - // Note that the yes and no buttons are swapped in this section - yesButtonText = getString(R.string.review_thanks_yes_button_text); - noButtonText = getString(R.string.review_thanks_no_button_text); - binding.buttonYes.setTextColor(Color.parseColor("#116aaa")); - binding.buttonNo.setTextColor(Color.parseColor("#228b22")); - binding.buttonNo.setOnClickListener(view -> { - getReviewActivity().reviewController.sendThanks(getReviewActivity()); - getReviewActivity().swipeToNext(); - }); - break; - default: - enableButtons(); - question = "How did we get here?"; - explanation = "No idea."; - yesButtonText = "yes"; - noButtonText = "no"; - } - - binding.tvReviewQuestion.setText(question); - binding.tvReviewQuestionContext.setText(explanation); - binding.buttonYes.setText(yesButtonText); - binding.buttonNo.setText(noButtonText); - return binding.getRoot(); - } - - - /** - * This method will be called when configuration changes happen - * - * @param outState - */ - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - - //Save user name when configuration changes happen - outState.putString(SAVED_USER, user); - } - - private ReviewController.ReviewCallback getReviewCallback() { - return new ReviewController - .ReviewCallback() { - @Override - public void onSuccess() { - getReviewActivity().runRandomizer(); - } - - @Override - public void onFailure() { - //do nothing - } - - @Override - public void onTokenException(final Exception e) { - if (e instanceof InvalidLoginTokenException){ - final String username = sessionManager.getUserName(); - final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener( - getActivity(), - requireActivity().getString(R.string.invalid_login_message), - username - ); - - CommonsApplication.getInstance().clearApplicationData( - requireActivity(), logoutListener); - - } - } - - /** - * This function is called when an image is being loaded - * to disable the review buttons - */ - @Override - public void disableButtons() { - ReviewImageFragment.this.disableButtons(); - } - - /** - * This function is called when an image has - * been loaded to enable the review buttons. - */ - @Override - public void enableButtons() { - ReviewImageFragment.this.enableButtons(); - } - }; - } - - /** - * This function is called when an image has - * been loaded to enable the review buttons. - */ - public void enableButtons() { - binding.buttonYes.setEnabled(true); - binding.buttonYes.setAlpha(1); - binding.buttonNo.setEnabled(true); - binding.buttonNo.setAlpha(1); - } - - /** - * This function is called when an image is being loaded - * to disable the review buttons - */ - public void disableButtons() { - binding.buttonYes.setEnabled(false); - binding.buttonYes.setAlpha(0.5f); - binding.buttonNo.setEnabled(false); - binding.buttonNo.setAlpha(0.5f); - } - - void onYesButtonClicked() { - getReviewActivity().swipeToNext(); - } - - private ReviewActivity getReviewActivity() { - return (ReviewActivity) requireActivity(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - binding = null; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.kt new file mode 100644 index 000000000..6c922c99c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewImageFragment.kt @@ -0,0 +1,248 @@ +package fr.free.nrw.commons.review + +import android.graphics.Color +import android.os.Bundle +import android.text.Html +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException +import fr.free.nrw.commons.databinding.FragmentReviewImageBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import javax.inject.Inject + + +class ReviewImageFragment : CommonsDaggerSupportFragment() { + + companion object { + const val CATEGORY = 2 + private const val SPAM = 0 + private const val COPYRIGHT = 1 + private const val THANKS = 3 + } + + private var position: Int = 0 + private var binding: FragmentReviewImageBinding? = null + + @Inject + lateinit var sessionManager: SessionManager + + // Constant variable used to store user's key name for onSaveInstanceState method + private val SAVED_USER = "saved_user" + + // Variable that stores the value of user + private var user: String? = null + + fun update(position: Int) { + this.position = position + } + + private fun updateCategoriesQuestion(): String { + val media = reviewActivity.media + if (media?.categoriesHiddenStatus != null && isAdded) { + // Filter category name attribute from all categories + val categories = media.categoriesHiddenStatus.keys.map { key -> + var value = key + // Each category returned has a format like "Category:" + // so remove the prefix "Category:" + if (key.startsWith("Category:")) { + value = key.substring(9) + } + value + } + + val catString = categories.joinToString(", ") + if (catString.isNotEmpty() && binding?.tvReviewQuestionContext != null) { + val formattedCatString = "$catString" + val stringToConvertHtml = getString( + R.string.review_category_explanation, + formattedCatString + ) + return Html.fromHtml(stringToConvertHtml).toString() + } + } + return getString(R.string.review_no_category) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + position = requireArguments().getInt("position") + binding = FragmentReviewImageBinding.inflate(inflater, container, false) + + val question: String + var explanation: String? = null + val yesButtonText: String + val noButtonText: String + + binding?.buttonYes?.setOnClickListener { onYesButtonClicked() } + + when (position) { + SPAM -> { + question = getString(R.string.review_spam) + explanation = getString(R.string.review_spam_explanation) + yesButtonText = getString(R.string.yes) + noButtonText = getString(R.string.no) + binding?.buttonNo?.setOnClickListener { + reviewActivity.reviewController.reportSpam(requireActivity(), reviewCallback) + } + } + COPYRIGHT -> { + enableButtons() + question = getString(R.string.review_copyright) + explanation = getString(R.string.review_copyright_explanation) + yesButtonText = getString(R.string.yes) + noButtonText = getString(R.string.no) + binding?.buttonNo?.setOnClickListener { + reviewActivity.reviewController.reportPossibleCopyRightViolation( + requireActivity(), + reviewCallback + ) + } + } + CATEGORY -> { + enableButtons() + question = getString(R.string.review_category) + explanation = updateCategoriesQuestion() + yesButtonText = getString(R.string.yes) + noButtonText = getString(R.string.no) + binding?.buttonNo?.setOnClickListener { + reviewActivity.reviewController.reportWrongCategory( + requireActivity(), + reviewCallback + ) + reviewActivity.swipeToNext() + } + } + THANKS -> { + enableButtons() + question = getString(R.string.review_thanks) + + user = reviewActivity.reviewController.firstRevision?.user() + ?: savedInstanceState?.getString(SAVED_USER) + + //if the user is null because of whatsoever reason, review will not be sent anyways + if (!user.isNullOrEmpty()) { + explanation = getString(R.string.review_thanks_explanation, user) + } + + // Note that the yes and no buttons are swapped in this section + yesButtonText = getString(R.string.review_thanks_yes_button_text) + noButtonText = getString(R.string.review_thanks_no_button_text) + binding?.buttonYes?.setTextColor(Color.parseColor("#116aaa")) + binding?.buttonNo?.setTextColor(Color.parseColor("#228b22")) + binding?.buttonNo?.setOnClickListener { + reviewActivity.reviewController.sendThanks(requireActivity()) + reviewActivity.swipeToNext() + } + } + else -> { + enableButtons() + question = "How did we get here?" + explanation = "No idea." + yesButtonText = "yes" + noButtonText = "no" + } + } + + binding?.apply { + tvReviewQuestion.text = question + tvReviewQuestionContext.text = explanation + buttonYes.text = yesButtonText + buttonNo.text = noButtonText + } + return binding?.root + } + + /** + * This method will be called when configuration changes happen + * + * @param outState + */ + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + //Save user name when configuration changes happen + outState.putString(SAVED_USER, user) + } + + private val reviewCallback: ReviewController.ReviewCallback + get() = object : ReviewController.ReviewCallback { + override fun onSuccess() { + reviewActivity.runRandomizer() + } + + override fun onFailure() { + //do nothing + } + + override fun onTokenException(e: Exception) { + if (e is InvalidLoginTokenException) { + val username = sessionManager.userName + val logoutListener = activity?.let { + CommonsApplication.BaseLogoutListener( + it, + getString(R.string.invalid_login_message), + username + ) + } + + if (logoutListener != null) { + CommonsApplication.instance.clearApplicationData( + requireActivity(), logoutListener + ) + } + } + } + + override fun disableButtons() { + this@ReviewImageFragment.disableButtons() + } + + override fun enableButtons() { + this@ReviewImageFragment.enableButtons() + } + } + + /** + * This function is called when an image has + * been loaded to enable the review buttons. + */ + fun enableButtons() { + binding?.apply { + buttonYes.isEnabled = true + buttonYes.alpha = 1f + buttonNo.isEnabled = true + buttonNo.alpha = 1f + } + } + + /** + * This function is called when an image is being loaded + * to disable the review buttons + */ + fun disableButtons() { + binding?.apply { + buttonYes.isEnabled = false + buttonYes.alpha = 0.5f + buttonNo.isEnabled = false + buttonNo.alpha = 0.5f + } + } + + fun onYesButtonClicked() { + reviewActivity.swipeToNext() + } + + private val reviewActivity: ReviewActivity + get() = requireActivity() as ReviewActivity + + override fun onDestroy() { + super.onDestroy() + binding = null + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewPagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewPagerAdapter.java deleted file mode 100644 index 16b55c6e9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewPagerAdapter.java +++ /dev/null @@ -1,53 +0,0 @@ -package fr.free.nrw.commons.review; - -import android.os.Bundle; - -import android.view.ViewGroup; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; - -public class ReviewPagerAdapter extends FragmentStatePagerAdapter { - private ReviewImageFragment[] reviewImageFragments; - - /** - * this function return the instance of ReviewviewPage current item - */ - @Override - public Object instantiateItem(@NonNull ViewGroup container, int position) { - return super.instantiateItem(container, position); - } - - ReviewPagerAdapter(FragmentManager fm) { - super(fm); - reviewImageFragments = new ReviewImageFragment[]{ - new ReviewImageFragment(), - new ReviewImageFragment(), - new ReviewImageFragment(), - new ReviewImageFragment() - }; - } - - @Override - public int getCount() { - return reviewImageFragments.length; - } - - void updateFileInformation() { - for (int i = 0; i < getCount(); i++) { - ReviewImageFragment fragment = reviewImageFragments[i]; - fragment.update(i); - } - } - - - @Override - public Fragment getItem(int position) { - Bundle bundle = new Bundle(); - bundle.putInt("position", position); - reviewImageFragments[position].setArguments(bundle); - return reviewImageFragments[position]; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewPagerAdapter.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewPagerAdapter.kt new file mode 100644 index 000000000..9bbe14e65 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewPagerAdapter.kt @@ -0,0 +1,37 @@ +package fr.free.nrw.commons.review + +import android.os.Bundle + +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter + + +class ReviewPagerAdapter(fm: FragmentManager) : FragmentStatePagerAdapter(fm) { + private val reviewImageFragments: Array = arrayOf( + ReviewImageFragment(), + ReviewImageFragment(), + ReviewImageFragment(), + ReviewImageFragment() + ) + + override fun getCount(): Int { + return reviewImageFragments.size + } + + fun updateFileInformation() { + for (i in 0 until count) { + val fragment = reviewImageFragments[i] + fragment.update(i) + } + } + + override fun getItem(position: Int): Fragment { + val bundle = Bundle().apply { + putInt("position", position) + } + reviewImageFragments[position].arguments = bundle + return reviewImageFragments[position] + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewViewPager.java b/app/src/main/java/fr/free/nrw/commons/review/ReviewViewPager.java deleted file mode 100644 index 95740aac0..000000000 --- a/app/src/main/java/fr/free/nrw/commons/review/ReviewViewPager.java +++ /dev/null @@ -1,30 +0,0 @@ -package fr.free.nrw.commons.review; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.MotionEvent; - -import androidx.viewpager.widget.ViewPager; - -public class ReviewViewPager extends ViewPager { - - public ReviewViewPager(Context context) { - super(context); - } - - public ReviewViewPager(Context context, AttributeSet attrs) { - super(context, attrs); - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent event) { - // Never allow swiping to switch between pages - return false; - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - // Never allow swiping to switch between pages - return false; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/review/ReviewViewPager.kt b/app/src/main/java/fr/free/nrw/commons/review/ReviewViewPager.kt new file mode 100644 index 000000000..39de49189 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/review/ReviewViewPager.kt @@ -0,0 +1,25 @@ +package fr.free.nrw.commons.review + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent + +import androidx.viewpager.widget.ViewPager + +class ReviewViewPager @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : ViewPager(context, attrs) { + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + // Never allow swiping to switch between pages + return false + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + // Never allow swiping to switch between pages + return false + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/Prefs.java b/app/src/main/java/fr/free/nrw/commons/settings/Prefs.java deleted file mode 100644 index 334214347..000000000 --- a/app/src/main/java/fr/free/nrw/commons/settings/Prefs.java +++ /dev/null @@ -1,21 +0,0 @@ -package fr.free.nrw.commons.settings; - -public class Prefs { - public static String GLOBAL_PREFS = "fr.free.nrw.commons.preferences"; - - public static String TRACKING_ENABLED = "eventLogging"; - public static final String DEFAULT_LICENSE = "defaultLicense"; - public static final String UPLOADS_SHOWING = "uploadsshowing"; - public static final String MANAGED_EXIF_TAGS = "managed_exif_tags"; - public static final String DESCRIPTION_LANGUAGE = "languageDescription"; - public static final String APP_UI_LANGUAGE = "appUiLanguage"; - public static final String KEY_THEME_VALUE = "appThemePref"; - - public static class Licenses { - public static final String CC_BY_SA_3 = "CC BY-SA 3.0"; - public static final String CC_BY_3 = "CC BY 3.0"; - public static final String CC_BY_SA_4 = "CC BY-SA 4.0"; - public static final String CC_BY_4 = "CC BY 4.0"; - public static final String CC0 = "CC0"; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/Prefs.kt b/app/src/main/java/fr/free/nrw/commons/settings/Prefs.kt new file mode 100644 index 000000000..13e8efb57 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/settings/Prefs.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.settings + +object Prefs { + const val GLOBAL_PREFS = "fr.free.nrw.commons.preferences" + + const val TRACKING_ENABLED = "eventLogging" + const val DEFAULT_LICENSE = "defaultLicense" + const val UPLOADS_SHOWING = "uploadsShowing" + const val MANAGED_EXIF_TAGS = "managed_exif_tags" + const val DESCRIPTION_LANGUAGE = "languageDescription" + const val APP_UI_LANGUAGE = "appUiLanguage" + const val KEY_THEME_VALUE = "appThemePref" + + object Licenses { + const val CC_BY_SA_3 = "CC BY-SA 3.0" + const val CC_BY_3 = "CC BY 3.0" + const val CC_BY_SA_4 = "CC BY-SA 4.0" + const val CC_BY_4 = "CC BY 4.0" + const val CC0 = "CC0" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsActivity.java b/app/src/main/java/fr/free/nrw/commons/settings/SettingsActivity.java deleted file mode 100644 index ff5024b32..000000000 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsActivity.java +++ /dev/null @@ -1,69 +0,0 @@ -package fr.free.nrw.commons.settings; - -import android.os.Bundle; -import android.view.MenuItem; - -import android.view.View; -import androidx.appcompat.app.AppCompatDelegate; - -import fr.free.nrw.commons.databinding.ActivitySettingsBinding; -import fr.free.nrw.commons.theme.BaseActivity; - -/** - * allows the user to change the settings - */ -public class SettingsActivity extends BaseActivity { - - private ActivitySettingsBinding binding; -// private AppCompatDelegate settingsDelegate; - /** - * to be called when the activity starts - * @param savedInstanceState the previously saved state - */ - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = ActivitySettingsBinding.inflate(getLayoutInflater()); - final View view = binding.getRoot(); - setContentView(view); - - setSupportActionBar(binding.toolbarBinding.toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - } - - // Get an action bar - /** - * takes care of actions taken after the creation has happened - * @param savedInstanceState the saved state - */ - @Override - protected void onPostCreate(Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); -// if (settingsDelegate == null) { -// settingsDelegate = AppCompatDelegate.create(this, null); -// } -// settingsDelegate.onPostCreate(savedInstanceState); - } - - @Override - public boolean onSupportNavigateUp() { - onBackPressed(); - return true; - } - - /** - * Handle action-bar clicks - * @param item the selected item - * @return true on success, false on failure - */ - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - default: - return super.onOptionsItemSelected(item); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsActivity.kt b/app/src/main/java/fr/free/nrw/commons/settings/SettingsActivity.kt new file mode 100644 index 000000000..da79244bc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsActivity.kt @@ -0,0 +1,63 @@ +package fr.free.nrw.commons.settings + +import android.os.Bundle +import android.view.MenuItem +import fr.free.nrw.commons.databinding.ActivitySettingsBinding +import fr.free.nrw.commons.theme.BaseActivity + + +/** + * allows the user to change the settings + */ +class SettingsActivity : BaseActivity() { + + private lateinit var binding: ActivitySettingsBinding +// private var settingsDelegate: AppCompatDelegate? = null + + /** + * to be called when the activity starts + * @param savedInstanceState the previously saved state + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySettingsBinding.inflate(layoutInflater) + val view = binding.root + setContentView(view) + + setSupportActionBar(binding.toolbarBinding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + // Get an action bar + /** + * takes care of actions taken after the creation has happened + * @param savedInstanceState the saved state + */ + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) +// if (settingsDelegate == null) { +// settingsDelegate = AppCompatDelegate.create(this, null) +// } +// settingsDelegate?.onPostCreate(savedInstanceState) + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + + /** + * Handle action-bar clicks + * @param item the selected item + * @return true on success, false on failure + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java deleted file mode 100644 index 94e799aa2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java +++ /dev/null @@ -1,551 +0,0 @@ -package fr.free.nrw.commons.settings; - -import static android.content.Context.MODE_PRIVATE; - -import android.Manifest.permission; -import android.app.Activity; -import android.app.Dialog; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.net.Uri; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.View; -import android.widget.AdapterView; -import android.widget.AdapterView.OnItemClickListener; -import android.widget.EditText; -import android.widget.ListView; -import android.widget.TextView; -import androidx.activity.result.ActivityResultCallback; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.preference.ListPreference; -import androidx.preference.MultiSelectListPreference; -import androidx.preference.Preference; -import androidx.preference.Preference.OnPreferenceClickListener; -import androidx.preference.PreferenceFragmentCompat; -import androidx.preference.PreferenceGroupAdapter; -import androidx.preference.PreferenceScreen; -import androidx.preference.PreferenceViewHolder; -import androidx.recyclerview.widget.RecyclerView.Adapter; -import com.karumi.dexter.Dexter; -import com.karumi.dexter.MultiplePermissionsReport; -import com.karumi.dexter.PermissionToken; -import com.karumi.dexter.listener.PermissionRequest; -import com.karumi.dexter.listener.multi.MultiplePermissionsListener; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.campaigns.CampaignView; -import fr.free.nrw.commons.contributions.ContributionController; -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.location.LocationServiceManager; -import fr.free.nrw.commons.logging.CommonsLogSender; -import fr.free.nrw.commons.recentlanguages.Language; -import fr.free.nrw.commons.recentlanguages.RecentLanguagesAdapter; -import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao; -import fr.free.nrw.commons.upload.LanguagesAdapter; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.PermissionUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import javax.inject.Inject; -import javax.inject.Named; - -public class SettingsFragment extends PreferenceFragmentCompat { - - @Inject - @Named("default_preferences") - JsonKvStore defaultKvStore; - - @Inject - CommonsLogSender commonsLogSender; - - @Inject - RecentLanguagesDao recentLanguagesDao; - - @Inject - ContributionController contributionController; - - @Inject - LocationServiceManager locationManager; - - private ListPreference themeListPreference; - private Preference descriptionLanguageListPreference; - private Preference appUiLanguageListPreference; - private String keyLanguageListPreference; - private TextView recentLanguagesTextView; - private View separator; - private ListView languageHistoryListView; - private static final String GET_CONTENT_PICKER_HELP_URL = "https://commons-app.github.io/docs.html#get-content"; - private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback>() { - @Override - public void onActivityResult(Map result) { - boolean areAllGranted = true; - for (final boolean b : result.values()) { - areAllGranted = areAllGranted && b; - } - if (!areAllGranted && shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { - contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher); - } - } - }); - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - ApplicationlessInjection - .getInstance(getActivity().getApplicationContext()) - .getCommonsApplicationComponent() - .inject(this); - - // Set the preferences from an XML resource - setPreferencesFromResource(R.xml.preferences, rootKey); - - themeListPreference = findPreference(Prefs.KEY_THEME_VALUE); - prepareTheme(); - - MultiSelectListPreference multiSelectListPref = findPreference(Prefs.MANAGED_EXIF_TAGS); - if (multiSelectListPref != null) { - multiSelectListPref.setOnPreferenceChangeListener((preference, newValue) -> { - if (newValue instanceof HashSet && !((HashSet) newValue).contains(getString(R.string.exif_tag_location))) { - defaultKvStore.putBoolean("has_user_manually_removed_location", true); - } - return true; - }); - } - - Preference inAppCameraLocationPref = findPreference("inAppCameraLocationPref"); - - inAppCameraLocationPref.setOnPreferenceChangeListener( - (preference, newValue) -> { - boolean isInAppCameraLocationTurnedOn = (boolean) newValue; - if (isInAppCameraLocationTurnedOn) { - createDialogsAndHandleLocationPermissions(getActivity()); - } - return true; - } - ); - - // Gets current language code from shared preferences - String languageCode; - - appUiLanguageListPreference = findPreference("appUiDefaultLanguagePref"); - assert appUiLanguageListPreference != null; - keyLanguageListPreference = appUiLanguageListPreference.getKey(); - languageCode = getCurrentLanguageCode(keyLanguageListPreference); - assert languageCode != null; - if (languageCode.equals("")) { - // If current language code is empty, means none selected by user yet so use phone local - appUiLanguageListPreference.setSummary(Locale.getDefault().getDisplayLanguage()); - } else { - // If any language is selected by user previously, use it - Locale defLocale = createLocale(languageCode); - appUiLanguageListPreference.setSummary((defLocale).getDisplayLanguage(defLocale)); - } - appUiLanguageListPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - prepareAppLanguages(appUiLanguageListPreference.getKey()); - return true; - } - }); - - descriptionLanguageListPreference = findPreference("descriptionDefaultLanguagePref"); - assert descriptionLanguageListPreference != null; - keyLanguageListPreference = descriptionLanguageListPreference.getKey(); - languageCode = getCurrentLanguageCode(keyLanguageListPreference); - assert languageCode != null; - if (languageCode.equals("")) { - // If current language code is empty, means none selected by user yet so use phone local - descriptionLanguageListPreference.setSummary(Locale.getDefault().getDisplayLanguage()); - } else { - // If any language is selected by user previously, use it - Locale defLocale = createLocale(languageCode); - descriptionLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale)); - } - descriptionLanguageListPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - prepareAppLanguages(descriptionLanguageListPreference.getKey()); - return true; - } - }); - - Preference betaTesterPreference = findPreference("becomeBetaTester"); - betaTesterPreference.setOnPreferenceClickListener(preference -> { - Utils.handleWebUrl(getActivity(), Uri.parse(getResources().getString(R.string.beta_opt_in_link))); - return true; - }); - Preference sendLogsPreference = findPreference("sendLogFile"); - sendLogsPreference.setOnPreferenceClickListener(preference -> { - checkPermissionsAndSendLogs(); - return true; - }); - - Preference documentBasedPickerPreference = findPreference("openDocumentPhotoPickerPref"); - documentBasedPickerPreference.setOnPreferenceChangeListener( - (preference, newValue) -> { - boolean isGetContentPickerTurnedOn = !(boolean) newValue; - if (isGetContentPickerTurnedOn) { - showLocationLossWarning(); - } - return true; - } - ); - // Disable some settings when not logged in. - if (defaultKvStore.getBoolean("login_skipped", false)) { - findPreference("useExternalStorage").setEnabled(false); - findPreference("useAuthorName").setEnabled(false); - findPreference("displayNearbyCardView").setEnabled(false); - findPreference("descriptionDefaultLanguagePref").setEnabled(false); - findPreference("displayLocationPermissionForCardView").setEnabled(false); - findPreference(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE).setEnabled(false); - findPreference("managed_exif_tags").setEnabled(false); - findPreference("openDocumentPhotoPickerPref").setEnabled(false); - findPreference("inAppCameraLocationPref").setEnabled(false); - } - } - - /** - * Asks users to provide location access - * - * @param activity - */ - private void createDialogsAndHandleLocationPermissions(Activity activity) { - inAppCameraLocationPermissionLauncher.launch(new String[]{permission.ACCESS_FINE_LOCATION}); - } - - /** - * On some devices, the new Photo Picker with GET_CONTENT takeover - * redacts location tags from EXIF metadata - * - * Show warning to the user when ACTION_GET_CONTENT intent is enabled - */ - private void showLocationLossWarning() { - DialogUtil.showAlertDialog( - getActivity(), - null, - getString(R.string.location_loss_warning), - getString(R.string.ok), - getString(R.string.read_help_link), - () -> {}, - () -> Utils.handleWebUrl(requireContext(), Uri.parse(GET_CONTENT_PICKER_HELP_URL)), - null, - true - ); - } - - @Override - protected Adapter onCreateAdapter(final PreferenceScreen preferenceScreen) { - return new PreferenceGroupAdapter(preferenceScreen) { - @Override - public void onBindViewHolder(PreferenceViewHolder holder, int position) { - super.onBindViewHolder(holder, position); - Preference preference = getItem(position); - View iconFrame = holder.itemView.findViewById(R.id.icon_frame); - if (iconFrame != null) { - iconFrame.setVisibility(View.GONE); - } - } - }; - } - - /** - * Sets the theme pref - */ - private void prepareTheme() { - themeListPreference.setOnPreferenceChangeListener((preference, newValue) -> { - getActivity().recreate(); - return true; - }); - } - - /** - * Prepare and Show language selection dialog box - * Uses previously saved language if there is any, if not uses phone locale as initial language. - * Disable default/already selected language from dialog box - * Get ListPreference key and act accordingly for each ListPreference. - * saves value chosen by user to shared preferences - * to remember later and recall MainActivity to reflect language changes - * @param keyListPreference - */ - private void prepareAppLanguages(final String keyListPreference) { - - // Gets current language code from shared preferences - final String languageCode = getCurrentLanguageCode(keyListPreference); - final List recentLanguages = recentLanguagesDao.getRecentLanguages(); - HashMap selectedLanguages = new HashMap<>(); - - if (keyListPreference.equals("appUiDefaultLanguagePref")) { - - assert languageCode != null; - if (languageCode.equals("")) { - selectedLanguages.put(0, Locale.getDefault().getLanguage()); - } else { - selectedLanguages.put(0, languageCode); - } - } else if (keyListPreference.equals("descriptionDefaultLanguagePref")) { - - assert languageCode != null; - if (languageCode.equals("")) { - selectedLanguages.put(0, Locale.getDefault().getLanguage()); - - } else { - selectedLanguages.put(0, languageCode); - } - } - - LanguagesAdapter languagesAdapter = new LanguagesAdapter( - getActivity(), - selectedLanguages - ); - - Dialog dialog = new Dialog(getActivity()); - dialog.setContentView(R.layout.dialog_select_language); - dialog.setCanceledOnTouchOutside(true); - dialog.getWindow().setLayout((int)(getActivity().getResources().getDisplayMetrics().widthPixels*0.90), - (int)(getActivity().getResources().getDisplayMetrics().heightPixels*0.90)); - dialog.show(); - - EditText editText = dialog.findViewById(R.id.search_language); - ListView listView = dialog.findViewById(R.id.language_list); - languageHistoryListView = dialog.findViewById(R.id.language_history_list); - recentLanguagesTextView = dialog.findViewById(R.id.recent_searches); - separator = dialog.findViewById(R.id.separator); - - setUpRecentLanguagesSection(recentLanguages, selectedLanguages); - - listView.setAdapter(languagesAdapter); - - editText.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence charSequence, int i, int i1, - int i2) { - hideRecentLanguagesSection(); - } - - @Override - public void onTextChanged(CharSequence charSequence, int i, int i1, - int i2) { - languagesAdapter.getFilter().filter(charSequence); - } - - @Override - public void afterTextChanged(Editable editable) { - - } - }); - - languageHistoryListView.setOnItemClickListener((adapterView, view, position, id) -> { - onRecentLanguageClicked(keyListPreference, dialog, adapterView, position); - }); - - listView.setOnItemClickListener(new OnItemClickListener() { - @Override - public void onItemClick(AdapterView adapterView, View view, int i, - long l) { - String languageCode = ((LanguagesAdapter) adapterView.getAdapter()) - .getLanguageCode(i); - final String languageName = ((LanguagesAdapter) adapterView.getAdapter()) - .getLanguageName(i); - final boolean isExists = recentLanguagesDao.findRecentLanguage(languageCode); - if (isExists) { - recentLanguagesDao.deleteRecentLanguage(languageCode); - } - recentLanguagesDao.addRecentLanguage(new Language(languageName, languageCode)); - saveLanguageValue(languageCode, keyListPreference); - Locale defLocale = createLocale(languageCode); - if(keyListPreference.equals("appUiDefaultLanguagePref")) { - appUiLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale)); - setLocale(requireActivity(), languageCode); - getActivity().recreate(); - final Intent intent = new Intent(getActivity(), MainActivity.class); - startActivity(intent); - }else { - descriptionLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale)); - } - dialog.dismiss(); - } - }); - - dialog.setOnDismissListener( - dialogInterface -> languagesAdapter.getFilter().filter("")); - } - - /** - * Set up recent languages section - * - * @param recentLanguages recently used languages - * @param selectedLanguages selected languages - */ - private void setUpRecentLanguagesSection(List recentLanguages, - HashMap selectedLanguages) { - if (recentLanguages.isEmpty()) { - languageHistoryListView.setVisibility(View.GONE); - recentLanguagesTextView.setVisibility(View.GONE); - separator.setVisibility(View.GONE); - } else { - if (recentLanguages.size() > 5) { - for (int i = recentLanguages.size()-1; i >=5; i--) { - recentLanguagesDao - .deleteRecentLanguage(recentLanguages.get(i).getLanguageCode()); - } - } - languageHistoryListView.setVisibility(View.VISIBLE); - recentLanguagesTextView.setVisibility(View.VISIBLE); - separator.setVisibility(View.VISIBLE); - final RecentLanguagesAdapter recentLanguagesAdapter - = new RecentLanguagesAdapter( - getActivity(), - recentLanguagesDao.getRecentLanguages(), - selectedLanguages); - languageHistoryListView.setAdapter(recentLanguagesAdapter); - } - } - - /** - * Handles click event for recent language section - */ - private void onRecentLanguageClicked(String keyListPreference, Dialog dialog, AdapterView adapterView, - int position) { - final String recentLanguageCode = ((RecentLanguagesAdapter) adapterView.getAdapter()) - .getLanguageCode(position); - final String recentLanguageName = ((RecentLanguagesAdapter) adapterView.getAdapter()) - .getLanguageName(position); - final boolean isExists = recentLanguagesDao.findRecentLanguage(recentLanguageCode); - if (isExists) { - recentLanguagesDao.deleteRecentLanguage(recentLanguageCode); - } - recentLanguagesDao.addRecentLanguage( - new Language(recentLanguageName, recentLanguageCode)); - saveLanguageValue(recentLanguageCode, keyListPreference); - final Locale defLocale = createLocale(recentLanguageCode); - if (keyListPreference.equals("appUiDefaultLanguagePref")) { - appUiLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale)); - setLocale(requireActivity(), recentLanguageCode); - getActivity().recreate(); - final Intent intent = new Intent(getActivity(), MainActivity.class); - startActivity(intent); - } else { - descriptionLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale)); - } - dialog.dismiss(); - } - - /** - * Remove the section of recent languages - */ - private void hideRecentLanguagesSection() { - languageHistoryListView.setVisibility(View.GONE); - recentLanguagesTextView.setVisibility(View.GONE); - separator.setVisibility(View.GONE); - } - - /** - * Changing the default app language with selected one and save it to SharedPreferences - */ - public void setLocale(final Activity activity, String userSelectedValue) { - if (userSelectedValue.equals("")) { - userSelectedValue = Locale.getDefault().getLanguage(); - } - final Locale locale = createLocale(userSelectedValue); - Locale.setDefault(locale); - final Configuration configuration = new Configuration(); - configuration.locale = locale; - activity.getBaseContext().getResources().updateConfiguration(configuration, - activity.getBaseContext().getResources().getDisplayMetrics()); - - final SharedPreferences.Editor editor = activity.getSharedPreferences("Settings", MODE_PRIVATE).edit(); - editor.putString("language", userSelectedValue); - editor.apply(); - } - - /** - * Create Locale based on different types of language codes - * @param languageCode - * @return Locale and throws error for invalid language codes - */ - public static Locale createLocale(String languageCode) { - String[] parts = languageCode.split("-"); - switch (parts.length) { - case 1: - return new Locale(parts[0]); - case 2: - return new Locale(parts[0], parts[1]); - case 3: - return new Locale(parts[0], parts[1], parts[2]); - default: - throw new IllegalArgumentException("Invalid language code: " + languageCode); - } - } - - /** - * Save userselected language in List Preference - * @param userSelectedValue - * @param preferenceKey - */ - private void saveLanguageValue(final String userSelectedValue, final String preferenceKey) { - if (preferenceKey.equals("appUiDefaultLanguagePref")) { - defaultKvStore.putString(Prefs.APP_UI_LANGUAGE, userSelectedValue); - } else if (preferenceKey.equals("descriptionDefaultLanguagePref")) { - defaultKvStore.putString(Prefs.DESCRIPTION_LANGUAGE, userSelectedValue); - } - } - - /** - * Gets current language code from shared preferences - * @param preferenceKey - * @return - */ - private String getCurrentLanguageCode(final String preferenceKey) { - if (preferenceKey.equals("appUiDefaultLanguagePref")) { - return defaultKvStore.getString(Prefs.APP_UI_LANGUAGE, ""); - } - if (preferenceKey.equals("descriptionDefaultLanguagePref")) { - return defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, ""); - } - return null; - } - - /** - * First checks for external storage permissions and then sends logs via email - */ - private void checkPermissionsAndSendLogs() { - if (PermissionUtils.hasPermission(getActivity(), PermissionUtils.PERMISSIONS_STORAGE)) { - commonsLogSender.send(getActivity(), null); - } else { - requestExternalStoragePermissions(); - } - } - - /** - * Requests external storage permissions and shows a toast stating that log collection has - * started - */ - private void requestExternalStoragePermissions() { - Dexter.withActivity(getActivity()) - .withPermissions(PermissionUtils.PERMISSIONS_STORAGE) - .withListener(new MultiplePermissionsListener() { - @Override - public void onPermissionsChecked(MultiplePermissionsReport report) { - ViewUtil.showLongToast(getActivity(), - getResources().getString(R.string.log_collection_started)); - } - - @Override - public void onPermissionRationaleShouldBeShown( - List permissions, PermissionToken token) { - - } - }) - .onSameThread() - .check(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt new file mode 100644 index 000000000..fe93ebc8a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt @@ -0,0 +1,567 @@ +package fr.free.nrw.commons.settings + +import android.Manifest.permission +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Dialog +import android.content.Context.MODE_PRIVATE +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.widget.AdapterView +import android.widget.EditText +import android.widget.ListView +import android.widget.TextView +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.preference.ListPreference +import androidx.preference.MultiSelectListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceGroupAdapter +import androidx.preference.PreferenceScreen +import androidx.preference.PreferenceViewHolder +import androidx.recyclerview.widget.RecyclerView.Adapter +import com.karumi.dexter.Dexter +import com.karumi.dexter.MultiplePermissionsReport +import com.karumi.dexter.PermissionToken +import com.karumi.dexter.listener.PermissionRequest +import com.karumi.dexter.listener.multi.MultiplePermissionsListener +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.campaigns.CampaignView +import fr.free.nrw.commons.contributions.ContributionController +import fr.free.nrw.commons.contributions.MainActivity +import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.filepicker.FilePicker +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.logging.CommonsLogSender +import fr.free.nrw.commons.recentlanguages.Language +import fr.free.nrw.commons.recentlanguages.RecentLanguagesAdapter +import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao +import fr.free.nrw.commons.upload.LanguagesAdapter +import fr.free.nrw.commons.utils.DialogUtil +import fr.free.nrw.commons.utils.PermissionUtils +import fr.free.nrw.commons.utils.ViewUtil +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named + +class SettingsFragment : PreferenceFragmentCompat() { + + @Inject + @field: Named("default_preferences") + lateinit var defaultKvStore: JsonKvStore + + @Inject + lateinit var commonsLogSender: CommonsLogSender + + @Inject + lateinit var recentLanguagesDao: RecentLanguagesDao + + @Inject + lateinit var contributionController: ContributionController + + @Inject + lateinit var locationManager: LocationServiceManager + + private var themeListPreference: ListPreference? = null + private var descriptionLanguageListPreference: Preference? = null + private var appUiLanguageListPreference: Preference? = null + private var showDeletionButtonPreference: Preference? = null + private var keyLanguageListPreference: String? = null + private var recentLanguagesTextView: TextView? = null + private var separator: View? = null + private var languageHistoryListView: ListView? = null + private lateinit var inAppCameraLocationPermissionLauncher: ActivityResultLauncher> + private val GET_CONTENT_PICKER_HELP_URL = "https://commons-app.github.io/docs.html#get-content" + + private val cameraPickLauncherForResult: ActivityResultLauncher = + registerForActivityResult(StartActivityForResult()) { result -> + contributionController.handleActivityResultWithCallback( + requireActivity(), + object: FilePicker.HandleActivityResult { + override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) { + contributionController.onPictureReturnedFromCamera( + result, + requireActivity(), + callbacks + ) + } + }) + } + + /** + * to be called when the fragment creates preferences + * @param savedInstanceState the previously saved state + * @param rootKey the root key for preferences + */ + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + ApplicationlessInjection + .getInstance(requireActivity().applicationContext) + .commonsApplicationComponent + .inject(this) + + // Set the preferences from an XML resource + setPreferencesFromResource(R.xml.preferences, rootKey) + + themeListPreference = findPreference(Prefs.KEY_THEME_VALUE) + prepareTheme() + + val multiSelectListPref: MultiSelectListPreference? = findPreference( + Prefs.MANAGED_EXIF_TAGS + ) + multiSelectListPref?.setOnPreferenceChangeListener { _, newValue -> + if (newValue is HashSet<*> && !newValue.contains(getString(R.string.exif_tag_location))) + { + defaultKvStore.putBoolean("has_user_manually_removed_location", true) + } + true + } + + val inAppCameraLocationPref: Preference? = findPreference("inAppCameraLocationPref") + inAppCameraLocationPref?.setOnPreferenceChangeListener { _, newValue -> + val isInAppCameraLocationTurnedOn = newValue as Boolean + if (isInAppCameraLocationTurnedOn) { + createDialogsAndHandleLocationPermissions(requireActivity()) + } + true + } + + inAppCameraLocationPermissionLauncher = registerForActivityResult( + RequestMultiplePermissions() + ) { result -> + var areAllGranted = true + for (b in result.values) { + areAllGranted = areAllGranted && b + } + if ( + !areAllGranted + && + shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION) + ) { + contributionController.handleShowRationaleFlowCameraLocation( + requireActivity(), + inAppCameraLocationPermissionLauncher, + cameraPickLauncherForResult + ) + } + } + + // Gets current language code from shared preferences + var languageCode: String? + + appUiLanguageListPreference = findPreference("appUiDefaultLanguagePref") + appUiLanguageListPreference?.let { appUiLanguageListPreference -> + keyLanguageListPreference = appUiLanguageListPreference.key + languageCode = getCurrentLanguageCode(keyLanguageListPreference!!) + + languageCode?.let { code -> + if (code.isEmpty()) { + // If current language code is empty, means none selected by user yet so use + // phone locale + appUiLanguageListPreference.summary = Locale.getDefault().displayLanguage + } else { + // If any language is selected by user previously, use it + val defLocale = createLocale(code) + appUiLanguageListPreference.summary = defLocale.getDisplayLanguage(defLocale) + } + } + + appUiLanguageListPreference.setOnPreferenceClickListener { + prepareAppLanguages(keyLanguageListPreference!!) + true + } + } + + descriptionLanguageListPreference = findPreference("descriptionDefaultLanguagePref") + descriptionLanguageListPreference?.let { descriptionLanguageListPreference -> + languageCode = getCurrentLanguageCode(descriptionLanguageListPreference.key) + + languageCode?.let { code -> + if (code.isEmpty()) { + // If current language code is empty, means none selected by user yet so use + // phone locale + descriptionLanguageListPreference.summary = Locale.getDefault().displayLanguage + } else { + // If any language is selected by user previously, use it + val defLocale = createLocale(code) + descriptionLanguageListPreference.summary = defLocale.getDisplayLanguage( + defLocale + ) + } + } + + descriptionLanguageListPreference.setOnPreferenceClickListener { + prepareAppLanguages(it.key) + true + } + } + + showDeletionButtonPreference = findPreference("displayDeletionButton") + showDeletionButtonPreference?.setOnPreferenceChangeListener { _, newValue -> + val isEnabled = newValue as Boolean + // Save preference when user toggles the button + defaultKvStore.putBoolean("displayDeletionButton", isEnabled) + true + } + + val betaTesterPreference: Preference? = findPreference("becomeBetaTester") + betaTesterPreference?.setOnPreferenceClickListener { + Utils.handleWebUrl(requireActivity(), Uri.parse(getString(R.string.beta_opt_in_link))) + true + } + + val sendLogsPreference: Preference? = findPreference("sendLogFile") + sendLogsPreference?.setOnPreferenceClickListener { + checkPermissionsAndSendLogs() + true + } + + val documentBasedPickerPreference: Preference? = findPreference( + "openDocumentPhotoPickerPref" + ) + documentBasedPickerPreference?.setOnPreferenceChangeListener { _, newValue -> + val isGetContentPickerTurnedOn = newValue as Boolean + if (!isGetContentPickerTurnedOn) { + showLocationLossWarning() + } + true + } + + // Disable some settings when not logged in. + if (defaultKvStore.getBoolean("login_skipped", false)) { + findPreference("useExternalStorage")?.isEnabled = false + findPreference("useAuthorName")?.isEnabled = false + findPreference("displayNearbyCardView")?.isEnabled = false + findPreference("descriptionDefaultLanguagePref")?.isEnabled = false + findPreference("displayLocationPermissionForCardView")?.isEnabled = false + findPreference(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE)?.isEnabled = false + findPreference("managed_exif_tags")?.isEnabled = false + findPreference("openDocumentPhotoPickerPref")?.isEnabled = false + findPreference("inAppCameraLocationPref")?.isEnabled = false + } + } + + /** + * Asks users to provide location access + * + * @param activity + */ + private fun createDialogsAndHandleLocationPermissions(activity: Activity) { + inAppCameraLocationPermissionLauncher.launch(arrayOf(permission.ACCESS_FINE_LOCATION)) + } + + /** + * On some devices, the new Photo Picker with GET_CONTENT takeover + * redacts location tags from EXIF metadata + * + * Show warning to the user when ACTION_GET_CONTENT intent is enabled + */ + private fun showLocationLossWarning() { + DialogUtil.showAlertDialog( + requireActivity(), + null, + getString(R.string.location_loss_warning), + getString(R.string.ok), + getString(R.string.read_help_link), + { }, + { Utils.handleWebUrl(requireContext(), Uri.parse(GET_CONTENT_PICKER_HELP_URL)) }, + null + ) + } + + override fun onCreateAdapter(preferenceScreen: PreferenceScreen): Adapter + { + return object : PreferenceGroupAdapter(preferenceScreen) { + override fun onBindViewHolder(holder: PreferenceViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + val preference = getItem(position) + val iconFrame: View? = holder.itemView.findViewById(R.id.icon_frame) + iconFrame?.visibility = View.GONE + } + } + } + + /** + * Sets the theme pref + */ + private fun prepareTheme() { + themeListPreference?.setOnPreferenceChangeListener { _, _ -> + requireActivity().recreate() + true + } + } + + /** + * Prepare and Show language selection dialog box + * Uses previously saved language if there is any, if not uses phone locale as initial language. + * Disable default/already selected language from dialog box + * Get ListPreference key and act accordingly for each ListPreference. + * saves value chosen by user to shared preferences + * to remember later and recall MainActivity to reflect language changes + * @param keyListPreference + */ + private fun prepareAppLanguages(keyListPreference: String) { + // Gets current language code from shared preferences + val languageCode = getCurrentLanguageCode(keyListPreference) + val recentLanguages = recentLanguagesDao.getRecentLanguages() + val selectedLanguages = hashMapOf() + + if (keyListPreference == "appUiDefaultLanguagePref") { + if (languageCode.isNullOrEmpty()) { + selectedLanguages[0] = Locale.getDefault().language + } else { + selectedLanguages[0] = languageCode + } + } else if (keyListPreference == "descriptionDefaultLanguagePref") { + if (languageCode.isNullOrEmpty()) { + selectedLanguages[0] = Locale.getDefault().language + } else { + selectedLanguages[0] = languageCode + } + } + + val languagesAdapter = LanguagesAdapter(requireActivity(), selectedLanguages) + + val dialog = Dialog(requireActivity()) + dialog.setContentView(R.layout.dialog_select_language) + dialog.setCancelable(false) + dialog.window?.setLayout( + (resources.displayMetrics.widthPixels * 0.90).toInt(), + (resources.displayMetrics.heightPixels * 0.90).toInt() + ) + dialog.show() + + val editText: EditText = dialog.findViewById(R.id.search_language) + val listView: ListView = dialog.findViewById(R.id.language_list) + languageHistoryListView = dialog.findViewById(R.id.language_history_list) + recentLanguagesTextView = dialog.findViewById(R.id.recent_searches) + separator = dialog.findViewById(R.id.separator) + + setUpRecentLanguagesSection(recentLanguages, selectedLanguages) + + listView.adapter = languagesAdapter + + editText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(charSequence: CharSequence, start: Int, count: Int, after: Int) { + hideRecentLanguagesSection() + } + + override fun onTextChanged(charSequence: CharSequence, start: Int, before: Int, count: Int) { + languagesAdapter.filter.filter(charSequence) + } + + override fun afterTextChanged(editable: Editable?) {} + }) + + languageHistoryListView?.setOnItemClickListener { adapterView, _, position, _ -> + onRecentLanguageClicked(keyListPreference, dialog, adapterView, position) + } + + listView.setOnItemClickListener { adapterView, _, position, _ -> + val lCode = (adapterView.adapter as LanguagesAdapter).getLanguageCode(position) + val languageName = (adapterView.adapter as LanguagesAdapter).getLanguageName(position) + val isExists = recentLanguagesDao.findRecentLanguage(lCode) + if (isExists) { + recentLanguagesDao.deleteRecentLanguage(lCode) + } + recentLanguagesDao.addRecentLanguage(Language(languageName, lCode)) + saveLanguageValue(lCode, keyListPreference) + val defLocale = createLocale(lCode) + if (keyListPreference == "appUiDefaultLanguagePref") { + appUiLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale) + setLocale(requireActivity(), lCode) + requireActivity().recreate() + val intent = Intent(requireActivity(), MainActivity::class.java) + startActivity(intent) + } else { + descriptionLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale) + } + dialog.dismiss() + } + + dialog.setOnDismissListener { languagesAdapter.filter.filter("") } + } + + /** + * Set up recent languages section + * + * @param recentLanguages recently used languages + * @param selectedLanguages selected languages + */ + private fun setUpRecentLanguagesSection( + recentLanguages: List, + selectedLanguages: HashMap + ) { + if (recentLanguages.isEmpty()) { + languageHistoryListView?.visibility = View.GONE + recentLanguagesTextView?.visibility = View.GONE + separator?.visibility = View.GONE + } else { + if (recentLanguages.size > 5) { + for (i in recentLanguages.size - 1 downTo 5) { + recentLanguagesDao.deleteRecentLanguage(recentLanguages[i].languageCode) + } + } + languageHistoryListView?.visibility = View.VISIBLE + recentLanguagesTextView?.visibility = View.VISIBLE + separator?.visibility = View.VISIBLE + val recentLanguagesAdapter = RecentLanguagesAdapter( + requireActivity(), + recentLanguagesDao.getRecentLanguages(), + selectedLanguages + ) + languageHistoryListView?.adapter = recentLanguagesAdapter + } + } + + /** + * Handles click event for recent language section + */ + private fun onRecentLanguageClicked( + keyListPreference: String, + dialog: Dialog, + adapterView: AdapterView<*>, + position: Int + ) { + val recentLanguageCode = (adapterView.adapter as RecentLanguagesAdapter).getLanguageCode(position) + val recentLanguageName = (adapterView.adapter as RecentLanguagesAdapter).getLanguageName(position) + val isExists = recentLanguagesDao.findRecentLanguage(recentLanguageCode) + if (isExists) { + recentLanguagesDao.deleteRecentLanguage(recentLanguageCode) + } + recentLanguagesDao.addRecentLanguage(Language(recentLanguageName, recentLanguageCode)) + saveLanguageValue(recentLanguageCode, keyListPreference) + val defLocale = createLocale(recentLanguageCode) + if (keyListPreference == "appUiDefaultLanguagePref") { + appUiLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale) + setLocale(requireActivity(), recentLanguageCode) + requireActivity().recreate() + val intent = Intent(requireActivity(), MainActivity::class.java) + startActivity(intent) + } else { + descriptionLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale) + } + dialog.dismiss() + } + + /** + * Remove the section of recent languages + */ + private fun hideRecentLanguagesSection() { + languageHistoryListView?.visibility = View.GONE + recentLanguagesTextView?.visibility = View.GONE + separator?.visibility = View.GONE + } + + /** + * Changing the default app language with selected one and save it to SharedPreferences + */ + fun setLocale(activity: Activity, userSelectedValue: String) { + var selectedLanguage = userSelectedValue + if (selectedLanguage == "") { + selectedLanguage = Locale.getDefault().language + } + val locale = createLocale(selectedLanguage) + Locale.setDefault(locale) + val configuration = Configuration() + configuration.locale = locale + activity.baseContext.resources.updateConfiguration(configuration, activity.baseContext.resources.displayMetrics) + + val editor = activity.getSharedPreferences("Settings", MODE_PRIVATE).edit() + editor.putString("language", selectedLanguage) + editor.apply() + } + + companion object { + /** + * Create Locale based on different types of language codes + * @param languageCode + * @return Locale and throws error for invalid language codes + */ + fun createLocale(languageCode: String): Locale { + val parts = languageCode.split("-") + return when (parts.size) { + 1 -> Locale(parts[0]) + 2 -> Locale(parts[0], parts[1]) + 3 -> Locale(parts[0], parts[1], parts[2]) + else -> throw IllegalArgumentException("Invalid language code: $languageCode") + } + } + } + + /** + * Save userSelected language in List Preference + * @param userSelectedValue + * @param preferenceKey + */ + private fun saveLanguageValue(userSelectedValue: String, preferenceKey: String) { + when (preferenceKey) { + "appUiDefaultLanguagePref" -> defaultKvStore.putString(Prefs.APP_UI_LANGUAGE, userSelectedValue) + "descriptionDefaultLanguagePref" -> defaultKvStore.putString(Prefs.DESCRIPTION_LANGUAGE, userSelectedValue) + } + } + + /** + * Gets current language code from shared preferences + * @param preferenceKey + * @return + */ + private fun getCurrentLanguageCode(preferenceKey: String): String? { + return when (preferenceKey) { + "appUiDefaultLanguagePref" -> defaultKvStore.getString( + Prefs.APP_UI_LANGUAGE, "" + ) + "descriptionDefaultLanguagePref" -> defaultKvStore.getString( + Prefs.DESCRIPTION_LANGUAGE, "" + ) + else -> null + } + } + + /** + * First checks for external storage permissions and then sends logs via email + */ + private fun checkPermissionsAndSendLogs() { + if ( + PermissionUtils.hasPermission( + requireActivity(), + PermissionUtils.PERMISSIONS_STORAGE + ) + ) { + commonsLogSender.sendWithNullable(requireActivity(), null) + } else { + requestExternalStoragePermissions() + } + } + + /** + * Requests external storage permissions and shows a toast stating that log collection has + * started + */ + private fun requestExternalStoragePermissions() { + Dexter.withActivity(requireActivity()) + .withPermissions(*PermissionUtils.PERMISSIONS_STORAGE) + .withListener(object : MultiplePermissionsListener { + override fun onPermissionsChecked(report: MultiplePermissionsReport) { + ViewUtil.showLongToast(requireActivity(), getString(R.string.log_collection_started)) + } + + override fun onPermissionRationaleShouldBeShown( + permissions: List, token: PermissionToken + ) { + // No action needed + } + }) + .onSameThread() + .check() + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.java b/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.java deleted file mode 100644 index 95ec00dc6..000000000 --- a/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.java +++ /dev/null @@ -1,66 +0,0 @@ -package fr.free.nrw.commons.theme; - -import android.content.res.Configuration; -import android.os.Bundle; -import android.util.DisplayMetrics; -import android.view.WindowManager; -import javax.inject.Inject; -import javax.inject.Named; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.di.CommonsDaggerAppCompatActivity; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.utils.SystemThemeUtils; -import io.reactivex.disposables.CompositeDisposable; - -public abstract class BaseActivity extends CommonsDaggerAppCompatActivity { - @Inject - @Named("default_preferences") - public JsonKvStore defaultKvStore; - - @Inject - SystemThemeUtils systemThemeUtils; - - protected CompositeDisposable compositeDisposable = new CompositeDisposable(); - protected boolean wasPreviouslyDarkTheme; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - wasPreviouslyDarkTheme = systemThemeUtils.isDeviceInNightMode(); - setTheme(wasPreviouslyDarkTheme ? R.style.DarkAppTheme : R.style.LightAppTheme); - float fontScale = android.provider.Settings.System.getFloat( - getBaseContext().getContentResolver(), - android.provider.Settings.System.FONT_SCALE, - 1f); - adjustFontScale(getResources().getConfiguration(), fontScale); - } - - @Override - protected void onResume() { - // Restart activity if theme is changed - if (wasPreviouslyDarkTheme != systemThemeUtils.isDeviceInNightMode()) { - recreate(); - } - - super.onResume(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - compositeDisposable.clear(); - } - - /** - * Apply fontScale on device - */ - public void adjustFontScale(Configuration configuration, float scale) { - configuration.fontScale = scale; - final DisplayMetrics metrics = getResources().getDisplayMetrics(); - final WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); - wm.getDefaultDisplay().getMetrics(metrics); - metrics.scaledDensity = configuration.fontScale * metrics.density; - getBaseContext().getResources().updateConfiguration(configuration, metrics); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.kt b/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.kt new file mode 100644 index 000000000..d2d936460 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.kt @@ -0,0 +1,65 @@ +package fr.free.nrw.commons.theme + +import android.content.res.Configuration +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.WindowManager +import javax.inject.Inject +import javax.inject.Named +import fr.free.nrw.commons.R +import fr.free.nrw.commons.di.CommonsDaggerAppCompatActivity +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.utils.SystemThemeUtils +import io.reactivex.disposables.CompositeDisposable + + +abstract class BaseActivity : CommonsDaggerAppCompatActivity() { + + @Inject + @field:Named("default_preferences") + lateinit var defaultKvStore: JsonKvStore + + @Inject + lateinit var systemThemeUtils: SystemThemeUtils + + protected val compositeDisposable = CompositeDisposable() + protected var wasPreviouslyDarkTheme: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + wasPreviouslyDarkTheme = systemThemeUtils.isDeviceInNightMode() + setTheme(if (wasPreviouslyDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme) + + val fontScale = android.provider.Settings.System.getFloat( + baseContext.contentResolver, + android.provider.Settings.System.FONT_SCALE, + 1f + ) + adjustFontScale(resources.configuration, fontScale) + } + + override fun onResume() { + // Restart activity if theme is changed + if (wasPreviouslyDarkTheme != systemThemeUtils.isDeviceInNightMode()) { + recreate() + } + super.onResume() + } + + override fun onDestroy() { + super.onDestroy() + compositeDisposable.clear() + } + + /** + * Apply fontScale on device + */ + fun adjustFontScale(configuration: Configuration, scale: Float) { + configuration.fontScale = scale + val metrics = resources.displayMetrics + val wm = getSystemService(WINDOW_SERVICE) as WindowManager + wm.defaultDisplay.getMetrics(metrics) + metrics.scaledDensity = configuration.fontScale * metrics.density + baseContext.resources.updateConfiguration(configuration, metrics) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditText.java b/app/src/main/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditText.java deleted file mode 100644 index 0b82baf64..000000000 --- a/app/src/main/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditText.java +++ /dev/null @@ -1,65 +0,0 @@ -package fr.free.nrw.commons.ui; - -import android.content.Context; -import android.content.res.TypedArray; -import android.os.Build.VERSION; -import android.util.AttributeSet; -import com.google.android.material.textfield.TextInputEditText; -import fr.free.nrw.commons.R; - -public class PasteSensitiveTextInputEditText extends TextInputEditText { - - private boolean formattingAllowed = true; - - public PasteSensitiveTextInputEditText(final Context context) { - super(context); - } - - public PasteSensitiveTextInputEditText(final Context context, final AttributeSet attrs) { - super(context, attrs); - formattingAllowed = extractFormattingAttribute(context, attrs); - } - - @Override - public boolean onTextContextMenuItem(int id) { - - // if not paste command, or formatting is allowed, return default - if(id != android.R.id.paste || formattingAllowed){ - return super.onTextContextMenuItem(id); - } - - // if its paste and formatting not allowed - boolean proceeded; - if(VERSION.SDK_INT >= 23) { - proceeded = super.onTextContextMenuItem(android.R.id.pasteAsPlainText); - }else { - proceeded = super.onTextContextMenuItem(id); - if (proceeded && getText() != null) { - // rewrite with plain text so formatting is lost - setText(getText().toString()); - setSelection(getText().length()); - } - } - return proceeded; - } - - private boolean extractFormattingAttribute(Context context, AttributeSet attrs){ - - boolean formatAllowed = true; - - TypedArray a = context.getTheme().obtainStyledAttributes( - attrs, R.styleable.PasteSensitiveTextInputEditText, 0, 0); - - try { - formatAllowed = a.getBoolean( - R.styleable.PasteSensitiveTextInputEditText_allowFormatting, true); - } finally { - a.recycle(); - } - return formatAllowed; - } - - public void setFormattingAllowed(boolean formattingAllowed){ - this.formattingAllowed = formattingAllowed; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditText.kt b/app/src/main/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditText.kt new file mode 100644 index 000000000..56f795485 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/ui/PasteSensitiveTextInputEditText.kt @@ -0,0 +1,60 @@ +package fr.free.nrw.commons.ui + +import android.content.Context +import android.content.res.TypedArray +import android.os.Build +import android.os.Build.VERSION +import android.util.AttributeSet +import com.google.android.material.textfield.TextInputEditText +import fr.free.nrw.commons.R + + +class PasteSensitiveTextInputEditText @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : TextInputEditText(context, attrs) { + + private var formattingAllowed: Boolean = true + + init { + if (attrs != null) { + formattingAllowed = extractFormattingAttribute(context, attrs) + } + } + + override fun onTextContextMenuItem(id: Int): Boolean { + // if not paste command, or formatting is allowed, return default + if (id != android.R.id.paste || formattingAllowed) { + return super.onTextContextMenuItem(id) + } + + // if it's paste and formatting not allowed + val proceeded: Boolean = if (VERSION.SDK_INT >= 23) { + super.onTextContextMenuItem(android.R.id.pasteAsPlainText) + } else { + val success = super.onTextContextMenuItem(id) + if (success && text != null) { + // rewrite with plain text so formatting is lost + setText(text.toString()) + setSelection(text?.length ?: 0) + } + success + } + return proceeded + } + + private fun extractFormattingAttribute(context: Context, attrs: AttributeSet): Boolean { + val a = context.theme.obtainStyledAttributes( + attrs, R.styleable.PasteSensitiveTextInputEditText, 0, 0 + ) + return try { + a.getBoolean(R.styleable.PasteSensitiveTextInputEditText_allowFormatting, true) + } finally { + a.recycle() + } + } + + fun setFormattingAllowed(formattingAllowed: Boolean) { + this.formattingAllowed = formattingAllowed + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/ui/widget/HtmlTextView.java b/app/src/main/java/fr/free/nrw/commons/ui/widget/HtmlTextView.java deleted file mode 100644 index 21af5ee78..000000000 --- a/app/src/main/java/fr/free/nrw/commons/ui/widget/HtmlTextView.java +++ /dev/null @@ -1,36 +0,0 @@ -package fr.free.nrw.commons.ui.widget; - -import android.content.Context; -import android.text.method.LinkMovementMethod; -import android.util.AttributeSet; - -import androidx.appcompat.widget.AppCompatTextView; - -import fr.free.nrw.commons.utils.StringUtil; - -/** - * An {@link AppCompatTextView} which formats the text to HTML displayable text and makes any - * links clickable. - */ -public class HtmlTextView extends AppCompatTextView { - - /** - * Constructs a new instance of HtmlTextView - * @param context the context of the view - * @param attrs the set of attributes for the view - */ - public HtmlTextView(Context context, AttributeSet attrs) { - super(context, attrs); - - setMovementMethod(LinkMovementMethod.getInstance()); - setText(StringUtil.fromHtml(getText().toString())); - } - - /** - * Sets the text to be displayed - * @param newText the text to be displayed - */ - public void setHtmlText(String newText) { - setText(StringUtil.fromHtml(newText)); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/ui/widget/HtmlTextView.kt b/app/src/main/java/fr/free/nrw/commons/ui/widget/HtmlTextView.kt new file mode 100644 index 000000000..48433136f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/ui/widget/HtmlTextView.kt @@ -0,0 +1,32 @@ +package fr.free.nrw.commons.ui.widget + +import android.content.Context +import android.text.method.LinkMovementMethod +import android.util.AttributeSet + +import androidx.appcompat.widget.AppCompatTextView + +import fr.free.nrw.commons.utils.StringUtil + +/** + * An [AppCompatTextView] which formats the text to HTML displayable text and makes any + * links clickable. + */ +class HtmlTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatTextView(context, attrs) { + + init { + movementMethod = LinkMovementMethod.getInstance() + text = StringUtil.fromHtml(text.toString()) + } + + /** + * Sets the text to be displayed + * @param newText the text to be displayed + */ + fun setHtmlText(newText: String) { + text = StringUtil.fromHtml(newText) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/ui/widget/OverlayDialog.java b/app/src/main/java/fr/free/nrw/commons/ui/widget/OverlayDialog.java deleted file mode 100644 index f36219040..000000000 --- a/app/src/main/java/fr/free/nrw/commons/ui/widget/OverlayDialog.java +++ /dev/null @@ -1,70 +0,0 @@ -package fr.free.nrw.commons.ui.widget; - -import android.app.Dialog; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; -import android.os.Bundle; -import android.view.Gravity; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; - -import androidx.annotation.NonNull; -import androidx.fragment.app.DialogFragment; - -/** - * a formatted dialog fragment - * This class is used by NearbyInfoDialog - */ -public abstract class OverlayDialog extends DialogFragment { - - /** - * creates a DialogFragment with the correct style and theme - * @param savedInstanceState bundle re-constructed from a previous saved state - */ - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setStyle(STYLE_NO_FRAME, android.R.style.Theme_Holo_Light); - } - - /** - * When the view is created, sets the dialog layout to full screen - * - * @param view the view being used - * @param savedInstanceState bundle re-constructed from a previous saved state - */ - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - setDialogLayoutToFullScreen(); - super.onViewCreated(view, savedInstanceState); - } - - /** - * sets the dialog layout to fullscreen - */ - private void setDialogLayoutToFullScreen() { - Window window = getDialog().getWindow(); - WindowManager.LayoutParams wlp = window.getAttributes(); - window.requestFeature(Window.FEATURE_NO_TITLE); - wlp.gravity = Gravity.BOTTOM; - wlp.width = WindowManager.LayoutParams.MATCH_PARENT; - wlp.height = WindowManager.LayoutParams.MATCH_PARENT; - window.setAttributes(wlp); - } - - /** - * builds custom dialog container - * - * @param savedInstanceState the previously saved state - * @return the dialog - */ - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - Dialog dialog = super.onCreateDialog(savedInstanceState); - Window window = dialog.getWindow(); - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - return dialog; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/ui/widget/OverlayDialog.kt b/app/src/main/java/fr/free/nrw/commons/ui/widget/OverlayDialog.kt new file mode 100644 index 000000000..5d4bc2b46 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/ui/widget/OverlayDialog.kt @@ -0,0 +1,64 @@ +package fr.free.nrw.commons.ui.widget + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.Window +import android.view.WindowManager + +import androidx.fragment.app.DialogFragment + +/** + * A formatted dialog fragment + * This class is used by NearbyInfoDialog + */ +abstract class OverlayDialog : DialogFragment() { + + /** + * Creates a DialogFragment with the correct style and theme + * @param savedInstanceState bundle re-constructed from a previous saved state + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, android.R.style.Theme_Holo_Light) + } + + /** + * When the view is created, sets the dialog layout to full screen + * + * @param view the view being used + * @param savedInstanceState bundle re-constructed from a previous saved state + */ + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setDialogLayoutToFullScreen() + super.onViewCreated(view, savedInstanceState) + } + + /** + * Sets the dialog layout to fullscreen + */ + private fun setDialogLayoutToFullScreen() { + val window = dialog?.window ?: return + val wlp = window.attributes + window.requestFeature(Window.FEATURE_NO_TITLE) + wlp.gravity = Gravity.BOTTOM + wlp.width = WindowManager.LayoutParams.MATCH_PARENT + wlp.height = WindowManager.LayoutParams.MATCH_PARENT + window.attributes = wlp + } + + /** + * Builds custom dialog container + * + * @param savedInstanceState the previously saved state + * @return the dialog + */ + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + return dialog + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/EXIFReader.java b/app/src/main/java/fr/free/nrw/commons/upload/EXIFReader.java deleted file mode 100644 index 0dd13acea..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/EXIFReader.java +++ /dev/null @@ -1,36 +0,0 @@ -package fr.free.nrw.commons.upload; - -import androidx.exifinterface.media.ExifInterface; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import fr.free.nrw.commons.utils.ImageUtils; -import io.reactivex.Single; - -/** - * We try to minimize uploads from the Commons app that might be copyright violations. - * If an image does not have any Exif metadata, then it was likely downloaded from the internet, - * and is probably not an original work by the user. We detect these kinds of images by looking - * for the presence of some basic Exif metadata. - */ -@Singleton -public class EXIFReader { - @Inject - public EXIFReader() { - } - - public Single processMetadata(String path) { - try { - ExifInterface exif = new ExifInterface(path); - if (exif.getAttribute(ExifInterface.TAG_MAKE) != null - || exif.getAttribute(ExifInterface.TAG_DATETIME) != null) { - return Single.just(ImageUtils.IMAGE_OK); - } - } catch (Exception e) { - return Single.just(ImageUtils.FILE_NO_EXIF); - } - return Single.just(ImageUtils.FILE_NO_EXIF); - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/upload/EXIFReader.kt b/app/src/main/java/fr/free/nrw/commons/upload/EXIFReader.kt new file mode 100644 index 000000000..f97052065 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/EXIFReader.kt @@ -0,0 +1,31 @@ +package fr.free.nrw.commons.upload + +import androidx.exifinterface.media.ExifInterface +import androidx.exifinterface.media.ExifInterface.TAG_DATETIME +import androidx.exifinterface.media.ExifInterface.TAG_MAKE +import fr.free.nrw.commons.utils.ImageUtils.FILE_NO_EXIF +import fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Singleton + +/** + * We try to minimize uploads from the Commons app that might be copyright violations. + * If an image does not have any Exif metadata, then it was likely downloaded from the internet, + * and is probably not an original work by the user. We detect these kinds of images by looking + * for the presence of some basic Exif metadata. + */ +@Singleton +class EXIFReader @Inject constructor() { + fun processMetadata(path: String): Single = Single.just( + try { + if (ExifInterface(path).hasMakeOrDate) IMAGE_OK else FILE_NO_EXIF + } catch (e: Exception) { + FILE_NO_EXIF + } + ) + + private val ExifInterface.hasMakeOrDate get() = + getAttribute(TAG_MAKE) != null || getAttribute(TAG_DATETIME) != null +} + diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsFragment.kt index 876fb3cd3..6e0712ea9 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FailedUploadsFragment.kt @@ -10,6 +10,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import fr.free.nrw.commons.R import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.contributions.Contribution.Companion.STATE_FAILED import fr.free.nrw.commons.databinding.FragmentFailedUploadsBinding import fr.free.nrw.commons.di.CommonsDaggerSupportFragment import fr.free.nrw.commons.media.MediaClient @@ -43,7 +44,7 @@ class FailedUploadsFragment : private lateinit var adapter: FailedUploadsAdapter - var contributionsList = ArrayList() + var contributionsList = mutableListOf() private lateinit var uploadProgressActivity: UploadProgressActivity @@ -63,7 +64,7 @@ class FailedUploadsFragment : } if (StringUtils.isEmpty(userName)) { - userName = sessionManager!!.getUserName() + userName = sessionManager.userName } } @@ -71,7 +72,7 @@ class FailedUploadsFragment : inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { + ): View { binding = FragmentFailedUploadsBinding.inflate(layoutInflater) pendingUploadsPresenter.onAttachView(this) initAdapter() @@ -96,12 +97,12 @@ class FailedUploadsFragment : fun initRecyclerView() { binding.failedUploadsRecyclerView.setLayoutManager(LinearLayoutManager(this.context)) binding.failedUploadsRecyclerView.adapter = adapter - pendingUploadsPresenter!!.getFailedContributions() - pendingUploadsPresenter!!.failedContributionList.observe( + pendingUploadsPresenter.getFailedContributions() + pendingUploadsPresenter.failedContributionList.observe( viewLifecycleOwner, - ) { list: PagedList -> + ) { list: PagedList -> adapter.submitList(list) - contributionsList = ArrayList() + contributionsList = mutableListOf() list.forEach { if (it != null) { contributionsList.add(it) @@ -124,26 +125,22 @@ class FailedUploadsFragment : * Restarts all the failed uploads. */ fun restartUploads() { - if (contributionsList != null) { - pendingUploadsPresenter.restartUploads( - contributionsList, - 0, - this.requireContext().applicationContext, - ) - } + pendingUploadsPresenter.restartUploads( + contributionsList, + 0, + requireContext().applicationContext, + ) } /** * Restarts a specific upload. */ override fun restartUpload(index: Int) { - if (contributionsList != null) { - pendingUploadsPresenter.restartUpload( - contributionsList, - index, - this.requireContext().applicationContext, - ) - } + pendingUploadsPresenter.restartUpload( + contributionsList, + index, + requireContext().applicationContext, + ) } /** @@ -166,7 +163,7 @@ class FailedUploadsFragment : ViewUtil.showShortToast(context, R.string.cancelling_upload) pendingUploadsPresenter.deleteUpload( contribution, - this.requireContext().applicationContext, + requireContext().applicationContext, ) }, {}, @@ -177,28 +174,24 @@ class FailedUploadsFragment : * Deletes all the uploads after getting a confirmation from the user using Dialog. */ fun deleteUploads() { - if (contributionsList != null) { - DialogUtil.showAlertDialog( - requireActivity(), - String.format( - Locale.getDefault(), - requireActivity().getString(R.string.cancelling_all_the_uploads), - ), - String.format( - Locale.getDefault(), - requireActivity().getString(R.string.are_you_sure_that_you_want_cancel_all_the_uploads), - ), - String.format(Locale.getDefault(), requireActivity().getString(R.string.yes)), - String.format(Locale.getDefault(), requireActivity().getString(R.string.no)), - { - ViewUtil.showShortToast(context, R.string.cancelling_upload) - uploadProgressActivity.hidePendingIcons() - pendingUploadsPresenter.deleteUploads( - listOf(Contribution.STATE_FAILED), - ) - }, - {}, - ) - } + DialogUtil.showAlertDialog( + requireActivity(), + String.format( + Locale.getDefault(), + requireActivity().getString(R.string.cancelling_all_the_uploads), + ), + String.format( + Locale.getDefault(), + requireActivity().getString(R.string.are_you_sure_that_you_want_cancel_all_the_uploads), + ), + String.format(Locale.getDefault(), requireActivity().getString(R.string.yes)), + String.format(Locale.getDefault(), requireActivity().getString(R.string.no)), + { + ViewUtil.showShortToast(context, R.string.cancelling_upload) + uploadProgressActivity.hidePendingIcons() + pendingUploadsPresenter.deleteUploads(listOf(STATE_FAILED)) + }, + {}, + ) } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileMetadataUtils.java b/app/src/main/java/fr/free/nrw/commons/upload/FileMetadataUtils.java deleted file mode 100644 index 3a6db30a7..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileMetadataUtils.java +++ /dev/null @@ -1,59 +0,0 @@ -package fr.free.nrw.commons.upload; - -import timber.log.Timber; - -import static androidx.exifinterface.media.ExifInterface.TAG_ARTIST; -import static androidx.exifinterface.media.ExifInterface.TAG_BODY_SERIAL_NUMBER; -import static androidx.exifinterface.media.ExifInterface.TAG_CAMERA_OWNER_NAME; -import static androidx.exifinterface.media.ExifInterface.TAG_COPYRIGHT; -import static androidx.exifinterface.media.ExifInterface.TAG_GPS_ALTITUDE; -import static androidx.exifinterface.media.ExifInterface.TAG_GPS_ALTITUDE_REF; -import static androidx.exifinterface.media.ExifInterface.TAG_GPS_LATITUDE; -import static androidx.exifinterface.media.ExifInterface.TAG_GPS_LATITUDE_REF; -import static androidx.exifinterface.media.ExifInterface.TAG_GPS_LONGITUDE; -import static androidx.exifinterface.media.ExifInterface.TAG_GPS_LONGITUDE_REF; -import static androidx.exifinterface.media.ExifInterface.TAG_LENS_MAKE; -import static androidx.exifinterface.media.ExifInterface.TAG_LENS_MODEL; -import static androidx.exifinterface.media.ExifInterface.TAG_LENS_SERIAL_NUMBER; -import static androidx.exifinterface.media.ExifInterface.TAG_LENS_SPECIFICATION; -import static androidx.exifinterface.media.ExifInterface.TAG_MAKE; -import static androidx.exifinterface.media.ExifInterface.TAG_MODEL; -import static androidx.exifinterface.media.ExifInterface.TAG_SOFTWARE; - -/** - * Support utils for EXIF metadata handling - * - */ -public class FileMetadataUtils { - - /** - * Takes EXIF label from sharedPreferences as input and returns relevant EXIF tags - * - * @param pref EXIF sharedPreference label - * @return EXIF tags - */ - public static String[] getTagsFromPref(String pref) { - Timber.d("Retuning tags for pref:%s", pref); - switch (pref) { - case "Author": - return new String[]{TAG_ARTIST, TAG_CAMERA_OWNER_NAME}; - case "Copyright": - return new String[]{TAG_COPYRIGHT}; - case "Location": - return new String[]{TAG_GPS_LATITUDE, TAG_GPS_LATITUDE_REF, - TAG_GPS_LONGITUDE, TAG_GPS_LONGITUDE_REF, - TAG_GPS_ALTITUDE, TAG_GPS_ALTITUDE_REF}; - case "Camera Model": - return new String[]{TAG_MAKE, TAG_MODEL}; - case "Lens Model": - return new String[]{TAG_LENS_MAKE, TAG_LENS_MODEL, TAG_LENS_SPECIFICATION}; - case "Serial Numbers": - return new String[]{TAG_BODY_SERIAL_NUMBER, TAG_LENS_SERIAL_NUMBER}; - case "Software": - return new String[]{TAG_SOFTWARE}; - default: - return new String[]{}; - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileMetadataUtils.kt b/app/src/main/java/fr/free/nrw/commons/upload/FileMetadataUtils.kt new file mode 100644 index 000000000..3f27f6f1e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileMetadataUtils.kt @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.upload + +import androidx.exifinterface.media.ExifInterface +import timber.log.Timber + +/** + * Support utils for EXIF metadata handling + * + */ +object FileMetadataUtils { + /** + * Takes EXIF label from sharedPreferences as input and returns relevant EXIF tags + * + * @param pref EXIF sharedPreference label + * @return EXIF tags + */ + fun getTagsFromPref(pref: String): Array { + Timber.d("Retuning tags for pref:%s", pref) + return when (pref) { + "Author" -> arrayOf( + ExifInterface.TAG_ARTIST, + ExifInterface.TAG_CAMERA_OWNER_NAME + ) + + "Copyright" -> arrayOf( + ExifInterface.TAG_COPYRIGHT + ) + + "Location" -> arrayOf( + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF + ) + + "Camera Model" -> arrayOf( + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL + ) + + "Lens Model" -> arrayOf( + ExifInterface.TAG_LENS_MAKE, + ExifInterface.TAG_LENS_MODEL, + ExifInterface.TAG_LENS_SPECIFICATION + ) + + "Serial Numbers" -> arrayOf( + ExifInterface.TAG_BODY_SERIAL_NUMBER, + ExifInterface.TAG_LENS_SERIAL_NUMBER + ) + + "Software" -> arrayOf( + ExifInterface.TAG_SOFTWARE + ) + + else -> arrayOf() + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt index 68c6f13fb..d51ab1796 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt @@ -194,7 +194,7 @@ class FileProcessor requireNotNull(imageCoordinates.decimalCoords) compositeDisposable.add( apiCall - .request(imageCoordinates.decimalCoords) + .request(imageCoordinates.decimalCoords!!) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) .subscribe( @@ -220,7 +220,7 @@ class FileProcessor .concatMap { Observable.fromCallable { okHttpJsonApiClient.getNearbyPlaces( - imageCoordinates.latLng, + imageCoordinates.latLng!!, Locale.getDefault().language, it, ) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java b/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java deleted file mode 100644 index b45e4b57d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java +++ /dev/null @@ -1,182 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.content.ContentResolver; -import android.content.Context; -import android.net.Uri; -import android.webkit.MimeTypeMap; - -import androidx.exifinterface.media.ExifInterface; - -import fr.free.nrw.commons.location.LatLng; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.math.BigInteger; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -import timber.log.Timber; - -public class FileUtils { - - /** - * Get SHA1 of filePath from input stream - */ - static String getSHA1(InputStream is) { - - MessageDigest digest; - try { - digest = MessageDigest.getInstance("SHA1"); - } catch (NoSuchAlgorithmException e) { - Timber.e(e, "Exception while getting Digest"); - return ""; - } - - byte[] buffer = new byte[8192]; - int read; - try { - while ((read = is.read(buffer)) > 0) { - digest.update(buffer, 0, read); - } - byte[] md5sum = digest.digest(); - BigInteger bigInt = new BigInteger(1, md5sum); - String output = bigInt.toString(16); - // Fill to 40 chars - output = String.format("%40s", output).replace(' ', '0'); - Timber.i("File SHA1: %s", output); - - return output; - } catch (IOException e) { - Timber.e(e, "IO Exception"); - return ""; - } finally { - try { - is.close(); - } catch (IOException e) { - Timber.e(e, "Exception on closing MD5 input stream"); - } - } - } - - /** - * Get Geolocation of filePath from input filePath path - */ - static String getGeolocationOfFile(String filePath, LatLng inAppPictureLocation) { - - try { - ExifInterface exifInterface = new ExifInterface(filePath); - ImageCoordinates imageObj = new ImageCoordinates(exifInterface, inAppPictureLocation); - if (imageObj.getDecimalCoords() != null) { // If image has geolocation information in its EXIF - return imageObj.getDecimalCoords(); - } else { - return ""; - } - } catch (IOException e) { - e.printStackTrace(); - return ""; - } - } - - - /** - * Read and return the content of a resource filePath as string. - * - * @param fileName asset filePath's path (e.g. "/queries/radius_query_for_upload_wizard.rq") - * @return the content of the filePath - */ - public static String readFromResource(String fileName) throws IOException { - StringBuilder buffer = new StringBuilder(); - BufferedReader reader = null; - try { - InputStream inputStream = FileUtils.class.getResourceAsStream(fileName); - if (inputStream == null) { - throw new FileNotFoundException(fileName); - } - reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); - String line; - while ((line = reader.readLine()) != null) { - buffer.append(line).append("\n"); - } - } finally { - if (reader != null) { - reader.close(); - } - } - return buffer.toString(); - } - - /** - * Deletes files. - * - * @param file context - */ - public static boolean deleteFile(File file) { - boolean deletedAll = true; - if (file != null) { - if (file.isDirectory()) { - String[] children = file.list(); - for (String child : children) { - deletedAll = deleteFile(new File(file, child)) && deletedAll; - } - } else { - deletedAll = file.delete(); - } - } - - return deletedAll; - } - - public static String getMimeType(Context context, Uri uri) { - String mimeType; - if (uri.getScheme()!=null && uri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) { - ContentResolver cr = context.getContentResolver(); - mimeType = cr.getType(uri); - } else { - String fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri - .toString()); - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension( - fileExtension.toLowerCase()); - } - return mimeType; - } - - static String getFileExt(String fileName) { - //Default filePath extension - String extension = ".jpg"; - - int i = fileName.lastIndexOf('.'); - if (i > 0) { - extension = fileName.substring(i + 1); - } - return extension; - } - - static FileInputStream getFileInputStream(String filePath) throws FileNotFoundException { - return new FileInputStream(filePath); - } - - public static boolean recursivelyCreateDirs(String dirPath) { - File fileDir = new File(dirPath); - if (!fileDir.exists()) { - return fileDir.mkdirs(); - } - return true; - } - - /** - * Check if file exists in local dirs - */ - public static boolean fileExists(Uri localUri) { - try { - File file = new File(localUri.getPath()); - return file.exists(); - } catch (Exception e) { - Timber.d(e); - return false; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.kt b/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.kt new file mode 100644 index 000000000..1febd8e88 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.kt @@ -0,0 +1,159 @@ +package fr.free.nrw.commons.upload + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.exifinterface.media.ExifInterface +import fr.free.nrw.commons.location.LatLng +import timber.log.Timber +import java.io.BufferedReader +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.math.BigInteger +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.util.Locale + +object FileUtils { + + /** + * Get SHA1 of filePath from input stream + */ + fun getSHA1(stream: InputStream): String { + val digest: MessageDigest + try { + digest = MessageDigest.getInstance("SHA1") + } catch (e: NoSuchAlgorithmException) { + Timber.e(e, "Exception while getting Digest") + return "" + } + + val buffer = ByteArray(8192) + var read: Int + try { + while ((stream.read(buffer).also { read = it }) > 0) { + digest.update(buffer, 0, read) + } + val md5sum = digest.digest() + val bigInt = BigInteger(1, md5sum) + var output = bigInt.toString(16) + // Fill to 40 chars + output = String.format("%40s", output).replace(' ', '0') + Timber.i("File SHA1: %s", output) + + return output + } catch (e: IOException) { + Timber.e(e, "IO Exception") + return "" + } finally { + try { + stream.close() + } catch (e: IOException) { + Timber.e(e, "Exception on closing MD5 input stream") + } + } + } + + /** + * Get Geolocation of filePath from input filePath path + */ + fun getGeolocationOfFile(filePath: String, inAppPictureLocation: LatLng?): String? = try { + val exifInterface = ExifInterface(filePath) + val imageObj = ImageCoordinates(exifInterface, inAppPictureLocation) + if (imageObj.decimalCoords != null) { // If image has geolocation information in its EXIF + imageObj.decimalCoords + } else { + "" + } + } catch (e: IOException) { + Timber.e(e) + "" + } + + + /** + * Read and return the content of a resource filePath as string. + * + * @param fileName asset filePath's path (e.g. "/queries/radius_query_for_upload_wizard.rq") + * @return the content of the filePath + */ + @Throws(IOException::class) + fun readFromResource(fileName: String) = buildString { + try { + val inputStream = FileUtils::class.java.getResourceAsStream(fileName) ?: + throw FileNotFoundException(fileName) + + BufferedReader(InputStreamReader(inputStream, "UTF-8")).use { reader -> + var line: String? + while ((reader.readLine().also { line = it }) != null) { + append(line).append("\n") + } + } + } catch (e: Throwable) { + Timber.e(e) + } + } + + /** + * Deletes files. + * + * @param file context + */ + fun deleteFile(file: File?): Boolean { + var deletedAll = true + if (file != null) { + if (file.isDirectory) { + val children = file.list() + for (child in children!!) { + deletedAll = deleteFile(File(file, child)) && deletedAll + } + } else { + deletedAll = file.delete() + } + } + + return deletedAll + } + + fun getMimeType(context: Context, uri: Uri): String? { + val mimeType: String? + if (uri.scheme != null && uri.scheme == ContentResolver.SCHEME_CONTENT) { + val cr = context.contentResolver + mimeType = cr.getType(uri) + } else { + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension( + fileExtension.lowercase(Locale.getDefault()) + ) + } + return mimeType + } + + fun getFileExt(fileName: String): String { + //Default filePath extension + var extension = ".jpg" + + val i = fileName.lastIndexOf('.') + if (i > 0) { + extension = fileName.substring(i + 1) + } + return extension + } + + @Throws(FileNotFoundException::class) + fun getFileInputStream(filePath: String?): FileInputStream = + FileInputStream(filePath) + + fun recursivelyCreateDirs(dirPath: String): Boolean { + val fileDir = File(dirPath) + if (!fileDir.exists()) { + return fileDir.mkdirs() + } + return true + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileUtilsWrapper.java b/app/src/main/java/fr/free/nrw/commons/upload/FileUtilsWrapper.java deleted file mode 100644 index a35eeebe9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileUtilsWrapper.java +++ /dev/null @@ -1,94 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.content.Context; -import android.net.Uri; -import fr.free.nrw.commons.location.LatLng; -import java.io.BufferedInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Singleton; -import timber.log.Timber; - -@Singleton -public class FileUtilsWrapper { - - private final Context context; - - @Inject - public FileUtilsWrapper(final Context context) { - this.context = context; - } - - public String getFileExt(String fileName) { - return FileUtils.getFileExt(fileName); - } - - public String getSHA1(InputStream is) { - return FileUtils.getSHA1(is); - } - - public FileInputStream getFileInputStream(String filePath) throws FileNotFoundException { - return FileUtils.getFileInputStream(filePath); - } - - public String getGeolocationOfFile(String filePath, LatLng inAppPictureLocation) { - return FileUtils.getGeolocationOfFile(filePath, inAppPictureLocation); - } - - public String getMimeType(File file) { - return getMimeType(Uri.parse(file.getPath())); - } - - public String getMimeType(Uri uri) { - return FileUtils.getMimeType(context, uri); - } - - /** - * Takes a file as input and returns an Observable of files with the specified chunk size - */ - public List getFileChunks(File file, final int chunkSize) - throws IOException { - final byte[] buffer = new byte[chunkSize]; - - //try-with-resources to ensure closing stream - try (final FileInputStream fis = new FileInputStream(file); - final BufferedInputStream bis = new BufferedInputStream(fis)) { - final List buffers = new ArrayList<>(); - int size; - while ((size = bis.read(buffer)) > 0) { - buffers.add(writeToFile(Arrays.copyOf(buffer, size), file.getName(), - getFileExt(file.getName()))); - } - return buffers; - } - } - - /** - * Create a temp file containing the passed byte data. - */ - private File writeToFile(final byte[] data, final String fileName, - String fileExtension) - throws IOException { - final File file = File.createTempFile(fileName, fileExtension, context.getCacheDir()); - try { - if (!file.exists()) { - file.createNewFile(); - } - final FileOutputStream fos = new FileOutputStream(file); - fos.write(data); - fos.close(); - } catch (final Exception throwable) { - Timber.e(throwable, "Failed to create file"); - } - return file; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileUtilsWrapper.kt b/app/src/main/java/fr/free/nrw/commons/upload/FileUtilsWrapper.kt new file mode 100644 index 000000000..aa1f8aed6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileUtilsWrapper.kt @@ -0,0 +1,85 @@ +package fr.free.nrw.commons.upload + +import android.content.Context +import android.net.Uri +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.upload.FileUtils.getMimeType +import timber.log.Timber +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FileUtilsWrapper @Inject constructor(private val context: Context) { + fun getSHA1(stream: InputStream?): String = + stream?.let { FileUtils.getSHA1(it) } ?: "" + + @Throws(FileNotFoundException::class) + fun getFileInputStream(filePath: String?): FileInputStream = + FileUtils.getFileInputStream(filePath) + + fun getGeolocationOfFile(filePath: String, inAppPictureLocation: LatLng?): String? = + FileUtils.getGeolocationOfFile(filePath, inAppPictureLocation) + + fun getMimeType(file: File?): String? = + getMimeType(Uri.parse(file?.path)) + + fun getMimeType(uri: Uri): String? = + getMimeType(context, uri) + + /** + * Takes a file as input and returns an Observable of files with the specified chunk size + */ + @Throws(IOException::class) + fun getFileChunks(file: File?, chunkSize: Int): List { + if (file == null) return emptyList() + + val buffer = ByteArray(chunkSize) + + FileInputStream(file).use { fis -> + BufferedInputStream(fis).use { bis -> + val buffers: MutableList = ArrayList() + var size: Int + while ((bis.read(buffer).also { size = it }) > 0) { + buffers.add( + writeToFile( + buffer.copyOf(size), + file.name ?: "", + getFileExt(file.name) + ) + ) + } + return buffers + } + } + } + + private fun getFileExt(fileName: String): String = + FileUtils.getFileExt(fileName) + + /** + * Create a temp file containing the passed byte data. + */ + @Throws(IOException::class) + private fun writeToFile(data: ByteArray, fileName: String, fileExtension: String): File { + val file = File.createTempFile(fileName, fileExtension, context.cacheDir) + try { + if (!file.exists()) { + file.createNewFile() + } + + FileOutputStream(file).use { fos -> + fos.write(data) + } + } catch (throwable: Exception) { + Timber.e(throwable, "Failed to create file") + } + return file + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java b/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java deleted file mode 100644 index 8065fde56..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java +++ /dev/null @@ -1,190 +0,0 @@ -package fr.free.nrw.commons.upload; - -import static fr.free.nrw.commons.utils.ImageUtils.EMPTY_CAPTION; -import static fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS; -import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_DUPLICATE; -import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_KEEP; -import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK; - -import android.content.Context; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.media.MediaClient; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.utils.ImageUtilsWrapper; -import io.reactivex.Single; -import io.reactivex.schedulers.Schedulers; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Singleton; -import org.apache.commons.lang3.StringUtils; -import timber.log.Timber; - -/** - * Methods for pre-processing images to be uploaded - */ -@Singleton -public class ImageProcessingService { - - private final FileUtilsWrapper fileUtilsWrapper; - private final ImageUtilsWrapper imageUtilsWrapper; - private final ReadFBMD readFBMD; - private final EXIFReader EXIFReader; - private final MediaClient mediaClient; - - @Inject - public ImageProcessingService(FileUtilsWrapper fileUtilsWrapper, - ImageUtilsWrapper imageUtilsWrapper, - ReadFBMD readFBMD, EXIFReader EXIFReader, - MediaClient mediaClient, Context context) { - this.fileUtilsWrapper = fileUtilsWrapper; - this.imageUtilsWrapper = imageUtilsWrapper; - this.readFBMD = readFBMD; - this.EXIFReader = EXIFReader; - this.mediaClient = mediaClient; - } - - - /** - * Check image quality before upload - checks duplicate image - checks dark image - checks - * geolocation for image - * - * @param uploadItem UploadItem whose quality is to be checked - * @param inAppPictureLocation In app picture location (if any) - * @return Quality of UploadItem - */ - Single validateImage(UploadItem uploadItem, LatLng inAppPictureLocation) { - int currentImageQuality = uploadItem.getImageQuality(); - Timber.d("Current image quality is %d", currentImageQuality); - if (currentImageQuality == IMAGE_KEEP || currentImageQuality == IMAGE_OK) { - return Single.just(IMAGE_OK); - } - Timber.d("Checking the validity of image"); - String filePath = uploadItem.getMediaUri().getPath(); - - return Single.zip( - checkDuplicateImage(filePath), - checkImageGeoLocation(uploadItem.getPlace(), filePath, inAppPictureLocation), - checkDarkImage(filePath), - checkFBMD(filePath), - checkEXIF(filePath), - (duplicateImage, wrongGeoLocation, darkImage, fbmd, exif) -> { - Timber.d("duplicate: %d, geo: %d, dark: %d" + "fbmd:" + fbmd + "exif:" - + exif, - duplicateImage, wrongGeoLocation, darkImage); - return duplicateImage | wrongGeoLocation | darkImage | fbmd | exif; - } - ); - } - - /** - * Checks caption of the given UploadItem - * - * @param uploadItem UploadItem whose caption is to be verified - * @return Quality of caption of the UploadItem - */ - Single validateCaption(UploadItem uploadItem) { - int currentImageQuality = uploadItem.getImageQuality(); - Timber.d("Current image quality is %d", currentImageQuality); - if (currentImageQuality == IMAGE_KEEP) { - return Single.just(IMAGE_OK); - } - Timber.d("Checking the validity of caption"); - - return validateItemTitle(uploadItem); - } - - /** - * We want to discourage users from uploading images to Commons that were taken from Facebook. - * This attempts to detect whether an image was downloaded from Facebook by heuristically - * searching for metadata that is specific to images that come from Facebook. - */ - private Single checkFBMD(String filepath) { - return readFBMD.processMetadata(filepath); - } - - /** - * We try to minimize uploads from the Commons app that might be copyright violations. If an - * image does not have any Exif metadata, then it was likely downloaded from the internet, and - * is probably not an original work by the user. We detect these kinds of images by looking for - * the presence of some basic Exif metadata. - */ - private Single checkEXIF(String filepath) { - return EXIFReader.processMetadata(filepath); - } - - - /** - * Checks item caption - empty caption - existing caption - * - * @param uploadItem - * @return - */ - private Single validateItemTitle(UploadItem uploadItem) { - Timber.d("Checking for image title %s", uploadItem.getUploadMediaDetails()); - List captions = uploadItem.getUploadMediaDetails(); - if (captions.isEmpty()) { - return Single.just(EMPTY_CAPTION); - } - - return mediaClient.checkPageExistsUsingTitle("File:" + uploadItem.getFileName()) - .map(doesFileExist -> { - Timber.d("Result for valid title is %s", doesFileExist); - return doesFileExist ? FILE_NAME_EXISTS : IMAGE_OK; - }) - .subscribeOn(Schedulers.io()); - } - - /** - * Checks for duplicate image - * - * @param filePath file to be checked - * @return IMAGE_DUPLICATE or IMAGE_OK - */ - Single checkDuplicateImage(String filePath) { - Timber.d("Checking for duplicate image %s", filePath); - return Single.fromCallable(() -> fileUtilsWrapper.getFileInputStream(filePath)) - .map(fileUtilsWrapper::getSHA1) - .flatMap(mediaClient::checkFileExistsUsingSha) - .map(b -> { - Timber.d("Result for duplicate image %s", b); - return b ? IMAGE_DUPLICATE : IMAGE_OK; - }) - .subscribeOn(Schedulers.io()); - } - - /** - * Checks for dark image - * - * @param filePath file to be checked - * @return IMAGE_DARK or IMAGE_OK - */ - private Single checkDarkImage(String filePath) { - Timber.d("Checking for dark image %s", filePath); - return imageUtilsWrapper.checkIfImageIsTooDark(filePath); - } - - /** - * Checks for image geolocation returns IMAGE_OK if the place is null or if the file doesn't - * contain a geolocation - * - * @param filePath file to be checked - * @return IMAGE_GEOLOCATION_DIFFERENT or IMAGE_OK - */ - private Single checkImageGeoLocation(Place place, String filePath, LatLng inAppPictureLocation) { - Timber.d("Checking for image geolocation %s", filePath); - if (place == null || StringUtils.isBlank(place.getWikiDataEntityId())) { - return Single.just(IMAGE_OK); - } - return Single.fromCallable(() -> filePath) - .flatMap(path -> Single.just(fileUtilsWrapper.getGeolocationOfFile(path, inAppPictureLocation))) - .flatMap(geoLocation -> { - if (StringUtils.isBlank(geoLocation)) { - return Single.just(IMAGE_OK); - } - return imageUtilsWrapper - .checkImageGeolocationIsDifferent(geoLocation, place.getLocation()); - }) - .subscribeOn(Schedulers.io()); - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.kt b/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.kt new file mode 100644 index 000000000..9fbb1f1e4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.kt @@ -0,0 +1,183 @@ +package fr.free.nrw.commons.upload + +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.media.MediaClient +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.utils.ImageUtils.EMPTY_CAPTION +import fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS +import fr.free.nrw.commons.utils.ImageUtils.IMAGE_DUPLICATE +import fr.free.nrw.commons.utils.ImageUtils.IMAGE_KEEP +import fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK +import fr.free.nrw.commons.utils.ImageUtilsWrapper +import io.reactivex.Single +import io.reactivex.functions.Function +import io.reactivex.schedulers.Schedulers +import org.apache.commons.lang3.StringUtils +import timber.log.Timber +import java.io.FileInputStream +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Methods for pre-processing images to be uploaded + */ +@Singleton +class ImageProcessingService @Inject constructor( + private val fileUtilsWrapper: FileUtilsWrapper, + private val imageUtilsWrapper: ImageUtilsWrapper, + private val readFBMD: ReadFBMD, + private val EXIFReader: EXIFReader, + private val mediaClient: MediaClient +) { + /** + * Check image quality before upload - checks duplicate image - checks dark image - checks + * geolocation for image + * + * @param uploadItem UploadItem whose quality is to be checked + * @param inAppPictureLocation In app picture location (if any) + * @return Quality of UploadItem + */ + fun validateImage(uploadItem: UploadItem, inAppPictureLocation: LatLng?): Single { + val currentImageQuality = uploadItem.imageQuality + Timber.d("Current image quality is %d", currentImageQuality) + if (currentImageQuality == IMAGE_KEEP || currentImageQuality == IMAGE_OK) { + return Single.just(IMAGE_OK) + } + + Timber.d("Checking the validity of image") + val filePath = uploadItem.mediaUri.path + + return Single.zip( + checkDuplicateImage(filePath), + checkImageGeoLocation(uploadItem.place, filePath, inAppPictureLocation), + checkDarkImage(filePath!!), + checkFBMD(filePath), + checkEXIF(filePath) + ) { duplicateImage: Int, wrongGeoLocation: Int, darkImage: Int, fbmd: Int, exif: Int -> + Timber.d( + "duplicate: %d, geo: %d, dark: %d, fbmd: %d, exif: %d", + duplicateImage, wrongGeoLocation, darkImage, fbmd, exif + ) + return@zip duplicateImage or wrongGeoLocation or darkImage or fbmd or exif + } + } + + /** + * Checks caption of the given UploadItem + * + * @param uploadItem UploadItem whose caption is to be verified + * @return Quality of caption of the UploadItem + */ + fun validateCaption(uploadItem: UploadItem): Single { + val currentImageQuality = uploadItem.imageQuality + Timber.d("Current image quality is %d", currentImageQuality) + if (currentImageQuality == IMAGE_KEEP) { + return Single.just(IMAGE_OK) + } + Timber.d("Checking the validity of caption") + + return validateItemTitle(uploadItem) + } + + /** + * We want to discourage users from uploading images to Commons that were taken from Facebook. + * This attempts to detect whether an image was downloaded from Facebook by heuristically + * searching for metadata that is specific to images that come from Facebook. + */ + private fun checkFBMD(filepath: String?): Single = + readFBMD.processMetadata(filepath) + + /** + * We try to minimize uploads from the Commons app that might be copyright violations. If an + * image does not have any Exif metadata, then it was likely downloaded from the internet, and + * is probably not an original work by the user. We detect these kinds of images by looking for + * the presence of some basic Exif metadata. + */ + private fun checkEXIF(filepath: String): Single = + EXIFReader.processMetadata(filepath) + + + /** + * Checks item caption - empty caption - existing caption + */ + private fun validateItemTitle(uploadItem: UploadItem): Single { + Timber.d("Checking for image title %s", uploadItem.uploadMediaDetails) + val captions = uploadItem.uploadMediaDetails + if (captions.isEmpty()) { + return Single.just(EMPTY_CAPTION) + } + + return mediaClient.checkPageExistsUsingTitle("File:" + uploadItem.fileName) + .map { doesFileExist: Boolean -> + Timber.d("Result for valid title is %s", doesFileExist) + if (doesFileExist) FILE_NAME_EXISTS else IMAGE_OK + } + .subscribeOn(Schedulers.io()) + } + + /** + * Checks for duplicate image + * + * @param filePath file to be checked + * @return IMAGE_DUPLICATE or IMAGE_OK + */ + fun checkDuplicateImage(filePath: String?): Single { + Timber.d("Checking for duplicate image %s", filePath) + return Single.fromCallable { fileUtilsWrapper.getFileInputStream(filePath) } + .map { stream: FileInputStream? -> + fileUtilsWrapper.getSHA1(stream) + } + .flatMap { fileSha: String? -> + mediaClient.checkFileExistsUsingSha(fileSha) + } + .map { + Timber.d("Result for duplicate image %s", it) + if (it) IMAGE_DUPLICATE else IMAGE_OK + } + .subscribeOn(Schedulers.io()) + } + + /** + * Checks for dark image + * + * @param filePath file to be checked + * @return IMAGE_DARK or IMAGE_OK + */ + private fun checkDarkImage(filePath: String): Single { + Timber.d("Checking for dark image %s", filePath) + return imageUtilsWrapper.checkIfImageIsTooDark(filePath) + } + + /** + * Checks for image geolocation returns IMAGE_OK if the place is null or if the file doesn't + * contain a geolocation + * + * @param filePath file to be checked + * @return IMAGE_GEOLOCATION_DIFFERENT or IMAGE_OK + */ + private fun checkImageGeoLocation( + place: Place?, + filePath: String?, + inAppPictureLocation: LatLng? + ): Single { + Timber.d("Checking for image geolocation %s", filePath) + if (place == null || StringUtils.isBlank(place.wikiDataEntityId)) { + return Single.just(IMAGE_OK) + } + + return Single.fromCallable { filePath } + .flatMap { path: String? -> + Single.just( + fileUtilsWrapper.getGeolocationOfFile(path!!, inAppPictureLocation) + ) + } + .flatMap { geoLocation: String? -> + if (geoLocation.isNullOrBlank()) { + return@flatMap Single.just(IMAGE_OK) + } + imageUtilsWrapper.checkImageGeolocationIsDifferent(geoLocation, place.getLocation()) + } + .subscribeOn(Schedulers.io()) + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt index 2847fa0c0..fa825d0a6 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt @@ -42,8 +42,8 @@ class LanguagesAdapter constructor( AppLanguageLookUpTable(context) init { - languageNamesList = language.localizedNames - languageCodesList = language.codes + languageNamesList = language.getLocalizedNames() + languageCodesList = language.getCodes() } private val filter = LanguageFilter() @@ -117,7 +117,7 @@ class LanguagesAdapter constructor( */ fun getIndexOfUserDefaultLocale(context: Context): Int { val userLanguageCode = context.locale?.language ?: return DEFAULT_INDEX - return language.codes.indexOf(userLanguageCode).takeIf { it >= 0 } ?: DEFAULT_INDEX + return language.getCodes().indexOf(userLanguageCode).takeIf { it >= 0 } ?: DEFAULT_INDEX } fun getIndexOfLanguageCode(languageCode: String): Int = languageCodesList.indexOf(languageCode) @@ -128,17 +128,17 @@ class LanguagesAdapter constructor( override fun performFiltering(constraint: CharSequence?): FilterResults { val filterResults = FilterResults() val temp: LinkedHashMap = LinkedHashMap() - if (constraint != null && language.localizedNames != null) { - val length: Int = language.localizedNames.size + if (constraint != null) { + val length: Int = language.getLocalizedNames().size var i = 0 while (i < length) { - val key: String = language.codes[i] - val value: String = language.localizedNames[i] + val key: String = language.getCodes()[i] + val value: String = language.getLocalizedNames()[i] val defaultlanguagecode = getIndexOfUserDefaultLocale(context) if (value.contains(constraint, true) || Locale(key) .getDisplayName( - Locale(language.codes[defaultlanguagecode]), + Locale(language.getCodes()[defaultlanguagecode]), ).contains(constraint, true) ) { temp[key] = value diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.java b/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.java deleted file mode 100644 index fb71da3c7..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.java +++ /dev/null @@ -1,127 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.content.Context; -import androidx.annotation.NonNull; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.filepicker.UploadableFile.DateTimeWithSource; -import fr.free.nrw.commons.settings.Prefs.Licenses; -import fr.free.nrw.commons.utils.ConfigUtils; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import javax.inject.Inject; -import org.apache.commons.lang3.StringUtils; -import timber.log.Timber; - -public class PageContentsCreator { - - //{{According to Exif data|2009-01-09}} - private static final String TEMPLATE_DATE_ACC_TO_EXIF = "{{According to Exif data|%s}}"; - - //2009-01-09 → 9 January 2009 - private static final String TEMPLATE_DATA_OTHER_SOURCE = "%s"; - - private final Context context; - - @Inject - public PageContentsCreator(final Context context) { - this.context = context; - } - - public String createFrom(final Contribution contribution) { - StringBuilder buffer = new StringBuilder(); - final Media media = contribution.getMedia(); - buffer - .append("== {{int:filedesc}} ==\n") - .append("{{Information\n") - .append("|description=").append(media.getFallbackDescription()).append("\n"); - if (contribution.getWikidataPlace() != null) { - buffer.append("{{ on Wikidata|").append(contribution.getWikidataPlace().getId()) - .append("}}"); - } - buffer - .append("|source=").append("{{own}}\n") - .append("|author=[[User:").append(media.getAuthor()).append("|") - .append(media.getAuthor()).append("]]\n"); - - final String templatizedCreatedDate = getTemplatizedCreatedDate( - contribution.getDateCreatedString(), contribution.getDateCreated(), contribution.getDateCreatedSource()); - if (!StringUtils.isBlank(templatizedCreatedDate)) { - buffer.append("|date=").append(templatizedCreatedDate); - } - - buffer.append("}}").append("\n"); - - //Only add Location template (e.g. {{Location|37.51136|-77.602615}} ) if coords is not null - final String decimalCoords = contribution.getDecimalCoords(); - if (decimalCoords != null) { - buffer.append("{{Location|").append(decimalCoords).append("}}").append("\n"); - } - - if (contribution.getWikidataPlace() != null && contribution.getWikidataPlace().isMonumentUpload()) { - buffer.append(String.format(Locale.ENGLISH, "{{Wiki Loves Monuments %d|1= %s}}\n", - Utils.getWikiLovesMonumentsYear(Calendar.getInstance()), contribution.getCountryCode())); - } - - buffer - .append("\n") - .append("== {{int:license-header}} ==\n") - .append(licenseTemplateFor(media.getLicense())).append("\n\n") - .append("{{Uploaded from Mobile|platform=Android|version=") - .append(ConfigUtils.getVersionNameWithSha(context)).append("}}\n"); - final List categories = media.getCategories(); - if (categories != null && categories.size() != 0) { - for (int i = 0; i < categories.size(); i++) { - buffer.append("\n[[Category:").append(categories.get(i)).append("]]"); - } - } else { - buffer.append("{{subst:unc}}"); - } - Timber.d("Template: %s", buffer.toString()); - return buffer.toString(); - } - - /** - * Returns upload date in either TEMPLATE_DATE_ACC_TO_EXIF or TEMPLATE_DATA_OTHER_SOURCE - * - * @param dateCreated - * @param dateCreatedSource - * @return - */ - private String getTemplatizedCreatedDate(String dateCreatedString, Date dateCreated, String dateCreatedSource) { - if (dateCreated != null) { - SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); - return String.format(Locale.ENGLISH, - isExif(dateCreatedSource) ? TEMPLATE_DATE_ACC_TO_EXIF : TEMPLATE_DATA_OTHER_SOURCE, - isExif(dateCreatedSource) ? dateCreatedString: dateFormat.format(dateCreated) - ) + "\n"; - } - return ""; - } - - private boolean isExif(String dateCreatedSource) { - return DateTimeWithSource.EXIF_SOURCE.equals(dateCreatedSource); - } - - @NonNull - private String licenseTemplateFor(String license) { - switch (license) { - case Licenses.CC_BY_3: - return "{{self|cc-by-3.0}}"; - case Licenses.CC_BY_4: - return "{{self|cc-by-4.0}}"; - case Licenses.CC_BY_SA_3: - return "{{self|cc-by-sa-3.0}}"; - case Licenses.CC_BY_SA_4: - return "{{self|cc-by-sa-4.0}}"; - case Licenses.CC0: - return "{{self|cc-zero}}"; - } - - throw new RuntimeException("Unrecognized license value: " + license); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.kt b/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.kt new file mode 100644 index 000000000..0c4ded8b2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.kt @@ -0,0 +1,106 @@ +package fr.free.nrw.commons.upload + +import android.content.Context +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.filepicker.UploadableFile.DateTimeWithSource +import fr.free.nrw.commons.settings.Prefs.Licenses +import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha +import org.apache.commons.lang3.StringUtils +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +class PageContentsCreator @Inject constructor(private val context: Context) { + fun createFrom(contribution: Contribution?): String = buildString { + val media = contribution?.media + append("== {{int:filedesc}} ==\n") + append("{{Information\n") + append("|description=").append(media?.fallbackDescription).append("\n") + if (contribution?.wikidataPlace != null) { + append("{{ on Wikidata|").append(contribution.wikidataPlace!!.id) + append("}}") + } + append("|source=").append("{{own}}\n") + append("|author=[[User:").append(media?.author).append("|") + append(media?.author).append("]]\n") + + val templatizedCreatedDate = getTemplatizedCreatedDate( + contribution?.dateCreatedString, + contribution?.dateCreated, + contribution?.dateCreatedSource + ) + if (!StringUtils.isBlank(templatizedCreatedDate)) { + append("|date=").append(templatizedCreatedDate) + } + + append("}}").append("\n") + + //Only add Location template (e.g. {{Location|37.51136|-77.602615}} ) if coords is not null + val decimalCoords = contribution?.decimalCoords + if (decimalCoords != null) { + append("{{Location|").append(decimalCoords).append("}}").append("\n") + } + + if (contribution?.wikidataPlace != null && contribution.wikidataPlace!!.isMonumentUpload) { + append( + String.format( + Locale.ENGLISH, + "{{Wiki Loves Monuments %d|1= %s}}\n", + Utils.getWikiLovesMonumentsYear(Calendar.getInstance()), + contribution.countryCode + ) + ) + } + + append("\n") + append("== {{int:license-header}} ==\n") + append(licenseTemplateFor(media?.license!!)).append("\n\n") + append("{{Uploaded from Mobile|platform=Android|version=") + append(context.getVersionNameWithSha()).append("}}\n") + val categories = media.categories + if (!categories.isNullOrEmpty()) { + categories.indices.forEach { + append("\n[[Category:").append(categories[it]).append("]]") + } + } else { + append("{{subst:unc}}") + } + } + + /** + * Returns upload date in either TEMPLATE_DATE_ACC_TO_EXIF or TEMPLATE_DATA_OTHER_SOURCE + */ + private fun getTemplatizedCreatedDate( + dateCreatedString: String?, dateCreated: Date?, dateCreatedSource: String? + ) = dateCreated?.let { + val dateFormat = SimpleDateFormat("yyyy-MM-dd") + String.format( + Locale.ENGLISH, + if (isExif(dateCreatedSource)) TEMPLATE_DATE_ACC_TO_EXIF else TEMPLATE_DATA_OTHER_SOURCE, + if (isExif(dateCreatedSource)) dateCreatedString else dateFormat.format(dateCreated) + ) + "\n" + } ?: "" + + private fun isExif(dateCreatedSource: String?): Boolean = + DateTimeWithSource.EXIF_SOURCE == dateCreatedSource + + private fun licenseTemplateFor(license: String) = when (license) { + Licenses.CC_BY_3 -> "{{self|cc-by-3.0}}" + Licenses.CC_BY_4 -> "{{self|cc-by-4.0}}" + Licenses.CC_BY_SA_3 -> "{{self|cc-by-sa-3.0}}" + Licenses.CC_BY_SA_4 -> "{{self|cc-by-sa-4.0}}" + Licenses.CC0 -> "{{self|cc-zero}}" + else -> throw RuntimeException("Unrecognized license value: $license") + } + + companion object { + //{{According to Exif data|2009-01-09}} + private const val TEMPLATE_DATE_ACC_TO_EXIF = "{{According to Exif data|%s}}" + + //2009-01-09 → 9 January 2009 + private const val TEMPLATE_DATA_OTHER_SOURCE = "%s" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsContract.java b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsContract.java deleted file mode 100644 index 8b86ecbd2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsContract.java +++ /dev/null @@ -1,31 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.content.Context; -import fr.free.nrw.commons.BasePresenter; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract; -import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract.View; - -/** - * The contract using which the PendingUploadsFragment or FailedUploadsFragment would communicate - * with its PendingUploadsPresenter - */ -public class PendingUploadsContract { - - /** - * Interface representing the view for uploads. - */ - public interface View { } - - /** - * Interface representing the user actions related to uploads. - */ - public interface UserActionListener extends - BasePresenter { - - /** - * Deletes a upload. - */ - void deleteUpload(Contribution contribution, Context context); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsContract.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsContract.kt new file mode 100644 index 000000000..6c18ba622 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsContract.kt @@ -0,0 +1,26 @@ +package fr.free.nrw.commons.upload + +import android.content.Context +import fr.free.nrw.commons.BasePresenter +import fr.free.nrw.commons.contributions.Contribution + +/** + * The contract using which the PendingUploadsFragment or FailedUploadsFragment would communicate + * with its PendingUploadsPresenter + */ +class PendingUploadsContract { + /** + * Interface representing the view for uploads. + */ + interface View + + /** + * Interface representing the user actions related to uploads. + */ + interface UserActionListener : BasePresenter { + /** + * Deletes a upload. + */ + fun deleteUpload(contribution: Contribution?, context: Context?) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsFragment.kt index 15cd9284a..044dd2192 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsFragment.kt @@ -10,6 +10,9 @@ import androidx.recyclerview.widget.LinearLayoutManager import fr.free.nrw.commons.CommonsApplication import fr.free.nrw.commons.R import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.contributions.Contribution.Companion.STATE_IN_PROGRESS +import fr.free.nrw.commons.contributions.Contribution.Companion.STATE_PAUSED +import fr.free.nrw.commons.contributions.Contribution.Companion.STATE_QUEUED import fr.free.nrw.commons.databinding.FragmentPendingUploadsBinding import fr.free.nrw.commons.di.CommonsDaggerSupportFragment import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog @@ -35,7 +38,8 @@ class PendingUploadsFragment : private lateinit var adapter: PendingUploadsAdapter private var contributionsSize = 0 - var contributionsList = ArrayList() + + private var contributionsList = mutableListOf() override fun onAttach(context: Context) { super.onAttach(context) @@ -48,7 +52,7 @@ class PendingUploadsFragment : inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { + ): View { super.onCreate(savedInstanceState) binding = FragmentPendingUploadsBinding.inflate(inflater, container, false) pendingUploadsPresenter.onAttachView(this) @@ -71,27 +75,24 @@ class PendingUploadsFragment : /** * Initializes the recycler view. */ - fun initRecyclerView() { + private fun initRecyclerView() { binding.pendingUploadsRecyclerView.setLayoutManager(LinearLayoutManager(this.context)) binding.pendingUploadsRecyclerView.adapter = adapter - pendingUploadsPresenter!!.setup() - pendingUploadsPresenter!!.totalContributionList.observe( - viewLifecycleOwner, - ) { list: PagedList -> + pendingUploadsPresenter.setup() + pendingUploadsPresenter.totalContributionList + .observe(viewLifecycleOwner) { list: PagedList -> contributionsSize = list.size - contributionsList = ArrayList() + contributionsList = mutableListOf() var pausedOrQueuedUploads = 0 list.forEach { if (it != null) { - if (it.state == Contribution.STATE_PAUSED || - it.state == Contribution.STATE_QUEUED || - it.state == Contribution.STATE_IN_PROGRESS + if (it.state == STATE_PAUSED || + it.state == STATE_QUEUED || + it.state == STATE_IN_PROGRESS ) { contributionsList.add(it) } - if (it.state == Contribution.STATE_PAUSED || - it.state == Contribution.STATE_QUEUED - ) { + if (it.state == STATE_PAUSED || it.state == STATE_QUEUED) { pausedOrQueuedUploads++ } } @@ -104,7 +105,7 @@ class PendingUploadsFragment : binding.nopendingTextView.visibility = View.GONE binding.pendingUplaodsLl.visibility = View.VISIBLE adapter.submitList(list) - binding.progressTextView.setText(contributionsSize.toString() + " uploads left") + binding.progressTextView.setText("$contributionsSize uploads left") if ((pausedOrQueuedUploads == contributionsSize) || CommonsApplication.isPaused) { uploadProgressActivity.setPausedIcon(true) } else { @@ -119,25 +120,20 @@ class PendingUploadsFragment : * And if the deleted upload is the last one, will set app off paused, allowing a fresh new start for future uploads. */ override fun deleteUpload(contribution: Contribution?) { + val activity = requireActivity() + val locale = Locale.getDefault() showAlertDialog( - requireActivity(), - String.format( - Locale.getDefault(), - requireActivity().getString(R.string.cancelling_upload), - ), - String.format( - Locale.getDefault(), - requireActivity().getString(R.string.cancel_upload_dialog), - ), - String.format(Locale.getDefault(), requireActivity().getString(R.string.yes)), - String.format(Locale.getDefault(), requireActivity().getString(R.string.no)), + activity, + String.format(locale, activity.getString(R.string.cancelling_upload)), + String.format(locale, activity.getString(R.string.cancel_upload_dialog)), + String.format(locale, activity.getString(R.string.yes)), + String.format(locale, activity.getString(R.string.no)), { if(contributionsList.size== 1) {CommonsApplication.isPaused = false} ViewUtil.showShortToast(context, R.string.cancelling_upload) pendingUploadsPresenter.deleteUpload( - contribution, - this.requireContext().applicationContext, + contribution, requireContext().applicationContext, ) }, {}, @@ -147,47 +143,35 @@ class PendingUploadsFragment : /** * Restarts all the paused uploads. */ - fun restartUploads() { - if (contributionsList != null) { - pendingUploadsPresenter.restartUploads( - contributionsList, - 0, - this.requireContext().applicationContext, - ) - } - } + fun restartUploads() = pendingUploadsPresenter.restartUploads( + contributionsList, 0, requireContext().applicationContext + ) /** * Pauses all the ongoing uploads. */ - fun pauseUploads() { - pendingUploadsPresenter.pauseUploads() - } + fun pauseUploads() = pendingUploadsPresenter.pauseUploads() /** * Cancels all the uploads after getting a confirmation from the user using Dialog. */ fun deleteUploads() { + val activity = requireActivity() + val locale = Locale.getDefault() showAlertDialog( - requireActivity(), - String.format( - Locale.getDefault(), - requireActivity().getString(R.string.cancelling_all_the_uploads), - ), - String.format( - Locale.getDefault(), - requireActivity().getString(R.string.are_you_sure_that_you_want_cancel_all_the_uploads), - ), - String.format(Locale.getDefault(), requireActivity().getString(R.string.yes)), - String.format(Locale.getDefault(), requireActivity().getString(R.string.no)), + activity, + String.format(locale, activity.getString(R.string.cancelling_all_the_uploads)), + String.format(locale, activity.getString(R.string.are_you_sure_that_you_want_cancel_all_the_uploads)), + String.format(locale, activity.getString(R.string.yes)), + String.format(locale, activity.getString(R.string.no)), { ViewUtil.showShortToast(context, R.string.cancelling_upload) uploadProgressActivity.hidePendingIcons() pendingUploadsPresenter.deleteUploads( listOf( - Contribution.STATE_QUEUED, - Contribution.STATE_IN_PROGRESS, - Contribution.STATE_PAUSED, + STATE_QUEUED, + STATE_IN_PROGRESS, + STATE_PAUSED, ), ) }, diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.java deleted file mode 100644 index 36c558519..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.java +++ /dev/null @@ -1,262 +0,0 @@ -package fr.free.nrw.commons.upload; - - -import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.paging.DataSource.Factory; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import androidx.work.ExistingWorkPolicy; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.contributions.ContributionBoundaryCallback; -import fr.free.nrw.commons.contributions.ContributionsRemoteDataSource; -import fr.free.nrw.commons.contributions.ContributionsRepository; -import fr.free.nrw.commons.di.CommonsApplicationModule; -import fr.free.nrw.commons.repository.UploadRepository; -import fr.free.nrw.commons.upload.PendingUploadsContract.UserActionListener; -import fr.free.nrw.commons.upload.PendingUploadsContract.View; -import fr.free.nrw.commons.upload.worker.WorkRequestHelper; -import io.reactivex.Scheduler; -import io.reactivex.disposables.CompositeDisposable; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Collections; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; -import timber.log.Timber; - -/** - * The presenter class for PendingUploadsFragment and FailedUploadsFragment - */ -public class PendingUploadsPresenter implements UserActionListener { - - private final ContributionBoundaryCallback contributionBoundaryCallback; - private final ContributionsRepository contributionsRepository; - private final UploadRepository uploadRepository; - private final Scheduler ioThreadScheduler; - - private final CompositeDisposable compositeDisposable; - private final ContributionsRemoteDataSource contributionsRemoteDataSource; - - LiveData> totalContributionList; - LiveData> failedContributionList; - - @Inject - PendingUploadsPresenter( - final ContributionBoundaryCallback contributionBoundaryCallback, - final ContributionsRemoteDataSource contributionsRemoteDataSource, - final ContributionsRepository contributionsRepository, - final UploadRepository uploadRepository, - @Named(CommonsApplicationModule.IO_THREAD) final Scheduler ioThreadScheduler) { - this.contributionBoundaryCallback = contributionBoundaryCallback; - this.contributionsRepository = contributionsRepository; - this.uploadRepository = uploadRepository; - this.ioThreadScheduler = ioThreadScheduler; - this.contributionsRemoteDataSource = contributionsRemoteDataSource; - compositeDisposable = new CompositeDisposable(); - } - - /** - * Setups the paged list of Pending Uploads. This method sets the configuration for paged list - * and ties it up with the live data object. This method can be tweaked to update the lazy - * loading behavior of the contributions list - */ - void setup() { - final PagedList.Config pagedListConfig = - (new PagedList.Config.Builder()) - .setPrefetchDistance(50) - .setPageSize(10).build(); - Factory factory; - - factory = contributionsRepository.fetchContributionsWithStatesSortedByDateUploadStarted( - Arrays.asList(Contribution.STATE_QUEUED, Contribution.STATE_IN_PROGRESS, - Contribution.STATE_PAUSED)); - LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, - pagedListConfig); - totalContributionList = livePagedListBuilder.build(); - } - - /** - * Setups the paged list of Failed Uploads. This method sets the configuration for paged list - * and ties it up with the live data object. This method can be tweaked to update the lazy - * loading behavior of the contributions list - */ - void getFailedContributions() { - final PagedList.Config pagedListConfig = - (new PagedList.Config.Builder()) - .setPrefetchDistance(50) - .setPageSize(10).build(); - Factory factory; - factory = contributionsRepository.fetchContributionsWithStatesSortedByDateUploadStarted( - Collections.singletonList(Contribution.STATE_FAILED)); - LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, - pagedListConfig); - failedContributionList = livePagedListBuilder.build(); - } - - @Override - public void onAttachView(@NonNull View view) { - - } - - @Override - public void onDetachView() { - compositeDisposable.clear(); - contributionsRemoteDataSource.dispose(); - contributionBoundaryCallback.dispose(); - } - - /** - * Deletes the specified upload (contribution) from the database. - * - * @param contribution The contribution object representing the upload to be deleted. - * @param context The context in which the operation is being performed. - */ - @Override - public void deleteUpload(final Contribution contribution, Context context) { - compositeDisposable.add(contributionsRepository - .deleteContributionFromDB(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - - /** - * Pauses all the uploads by changing the state of contributions from STATE_QUEUED and - * STATE_IN_PROGRESS to STATE_PAUSED in the database. - */ - public void pauseUploads() { - CommonsApplication.isPaused = true; - compositeDisposable.add(contributionsRepository - .updateContributionsWithStates( - List.of(Contribution.STATE_QUEUED, Contribution.STATE_IN_PROGRESS), - Contribution.STATE_PAUSED) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - - /** - * Deletes contributions from the database that match the specified states. - * - * @param states A list of integers representing the states of the contributions to be deleted. - */ - public void deleteUploads(List states) { - compositeDisposable.add(contributionsRepository - .deleteContributionsFromDBWithStates(states) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - - /** - * Restarts the uploads for the specified list of contributions starting from the given index. - * - * @param contributionList The list of contributions to be restarted. - * @param index The starting index in the list from which to restart uploads. - * @param context The context in which the operation is being performed. - */ - public void restartUploads(List contributionList, int index, Context context) { - CommonsApplication.isPaused = false; - if (index >= contributionList.size()) { - return; - } - Contribution it = contributionList.get(index); - if (it.getState() == Contribution.STATE_FAILED) { - it.setDateUploadStarted(Calendar.getInstance().getTime()); - if (it.getErrorInfo() == null) { - it.setChunkInfo(null); - it.setTransferred(0); - } - compositeDisposable.add(uploadRepository - .checkDuplicateImage(it.getLocalUriPath().getPath()) - .subscribeOn(ioThreadScheduler) - .subscribe(imageCheckResult -> { - if (imageCheckResult == IMAGE_OK) { - it.setState(Contribution.STATE_QUEUED); - compositeDisposable.add(contributionsRepository - .save(it) - .subscribeOn(ioThreadScheduler) - .doOnComplete(() -> { - restartUploads(contributionList, index + 1, context); - }) - .subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest( - context, ExistingWorkPolicy.KEEP))); - } else { - Timber.e("Contribution already exists"); - compositeDisposable.add(contributionsRepository - .deleteContributionFromDB(it) - .subscribeOn(ioThreadScheduler).doOnComplete(() -> { - restartUploads(contributionList, index + 1, context); - }) - .subscribe()); - } - }, throwable -> { - Timber.e(throwable); - restartUploads(contributionList, index + 1, context); - })); - } else { - it.setState(Contribution.STATE_QUEUED); - compositeDisposable.add(contributionsRepository - .save(it) - .subscribeOn(ioThreadScheduler) - .doOnComplete(() -> { - restartUploads(contributionList, index + 1, context); - }) - .subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest( - context, ExistingWorkPolicy.KEEP))); - } - } - - /** - * Restarts the upload for the specified list of contributions for the given index. - * - * @param contributionList The list of contributions. - * @param index The index in the list which to be restarted. - * @param context The context in which the operation is being performed. - */ - public void restartUpload(List contributionList, int index, Context context) { - CommonsApplication.isPaused = false; - if (index >= contributionList.size()) { - return; - } - Contribution it = contributionList.get(index); - if (it.getState() == Contribution.STATE_FAILED) { - it.setDateUploadStarted(Calendar.getInstance().getTime()); - if (it.getErrorInfo() == null) { - it.setChunkInfo(null); - it.setTransferred(0); - } - compositeDisposable.add(uploadRepository - .checkDuplicateImage(it.getLocalUriPath().getPath()) - .subscribeOn(ioThreadScheduler) - .subscribe(imageCheckResult -> { - if (imageCheckResult == IMAGE_OK) { - it.setState(Contribution.STATE_QUEUED); - compositeDisposable.add(contributionsRepository - .save(it) - .subscribeOn(ioThreadScheduler) - .subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest( - context, ExistingWorkPolicy.KEEP))); - } else { - Timber.e("Contribution already exists"); - compositeDisposable.add(contributionsRepository - .deleteContributionFromDB(it) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - })); - } else { - it.setState(Contribution.STATE_QUEUED); - compositeDisposable.add(contributionsRepository - .save(it) - .subscribeOn(ioThreadScheduler) - .subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest( - context, ExistingWorkPolicy.KEEP))); - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt new file mode 100644 index 000000000..324f988d4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt @@ -0,0 +1,256 @@ +package fr.free.nrw.commons.upload + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import androidx.work.ExistingWorkPolicy +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.contributions.Contribution.Companion.STATE_FAILED +import fr.free.nrw.commons.contributions.Contribution.Companion.STATE_IN_PROGRESS +import fr.free.nrw.commons.contributions.Contribution.Companion.STATE_PAUSED +import fr.free.nrw.commons.contributions.Contribution.Companion.STATE_QUEUED +import fr.free.nrw.commons.contributions.ContributionBoundaryCallback +import fr.free.nrw.commons.contributions.ContributionsRemoteDataSource +import fr.free.nrw.commons.contributions.ContributionsRepository +import fr.free.nrw.commons.di.CommonsApplicationModule +import fr.free.nrw.commons.repository.UploadRepository +import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest +import fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK +import io.reactivex.Scheduler +import io.reactivex.disposables.CompositeDisposable +import timber.log.Timber +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Named + + +/** + * The presenter class for PendingUploadsFragment and FailedUploadsFragment + */ +class PendingUploadsPresenter @Inject internal constructor( + private val contributionBoundaryCallback: ContributionBoundaryCallback, + private val contributionsRemoteDataSource: ContributionsRemoteDataSource, + private val contributionsRepository: ContributionsRepository, + private val uploadRepository: UploadRepository, + @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler +) : PendingUploadsContract.UserActionListener { + private val compositeDisposable = CompositeDisposable() + + lateinit var totalContributionList: LiveData> + lateinit var failedContributionList: LiveData> + + /** + * Setups the paged list of Pending Uploads. This method sets the configuration for paged list + * and ties it up with the live data object. This method can be tweaked to update the lazy + * loading behavior of the contributions list + */ + fun setup() { + val pagedListConfig = PagedList.Config.Builder() + .setPrefetchDistance(50) + .setPageSize(10).build() + + val factory = contributionsRepository.fetchContributionsWithStatesSortedByDateUploadStarted( + listOf(STATE_QUEUED, STATE_IN_PROGRESS, STATE_PAUSED) + ) + totalContributionList = LivePagedListBuilder(factory, pagedListConfig).build() + } + + /** + * Setups the paged list of Failed Uploads. This method sets the configuration for paged list + * and ties it up with the live data object. This method can be tweaked to update the lazy + * loading behavior of the contributions list + */ + fun getFailedContributions() { + val pagedListConfig = PagedList.Config.Builder() + .setPrefetchDistance(50) + .setPageSize(10).build() + + val factory = contributionsRepository.fetchContributionsWithStatesSortedByDateUploadStarted( + listOf(STATE_FAILED) + ) + failedContributionList = LivePagedListBuilder(factory, pagedListConfig).build() + } + + override fun onAttachView(view: PendingUploadsContract.View) { + } + + override fun onDetachView() { + compositeDisposable.clear() + contributionsRemoteDataSource.dispose() + contributionBoundaryCallback.dispose() + } + + /** + * Deletes the specified upload (contribution) from the database. + * + * @param contribution The contribution object representing the upload to be deleted. + * @param context The context in which the operation is being performed. + */ + override fun deleteUpload(contribution: Contribution?, context: Context?) { + compositeDisposable.add( + contributionsRepository + .deleteContributionFromDB(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe() + ) + } + + /** + * Pauses all the uploads by changing the state of contributions from STATE_QUEUED and + * STATE_IN_PROGRESS to STATE_PAUSED in the database. + */ + fun pauseUploads() { + CommonsApplication.isPaused = true + compositeDisposable.add( + contributionsRepository + .updateContributionsWithStates( + listOf(STATE_QUEUED, STATE_IN_PROGRESS), + STATE_PAUSED + ) + .subscribeOn(ioThreadScheduler) + .subscribe() + ) + } + + /** + * Deletes contributions from the database that match the specified states. + * + * @param states A list of integers representing the states of the contributions to be deleted. + */ + fun deleteUploads(states: List) { + compositeDisposable.add( + contributionsRepository + .deleteContributionsFromDBWithStates(states) + .subscribeOn(ioThreadScheduler) + .subscribe() + ) + } + + /** + * Restarts the uploads for the specified list of contributions starting from the given index. + * + * @param contributionList The list of contributions to be restarted. + * @param index The starting index in the list from which to restart uploads. + * @param context The context in which the operation is being performed. + */ + fun restartUploads(contributionList: List, index: Int, context: Context) { + CommonsApplication.isPaused = false + if (index >= contributionList.size) { + return + } + val contribution = contributionList[index] + if (contribution.state == STATE_FAILED) { + contribution.dateUploadStarted = Calendar.getInstance().time + if (contribution.errorInfo == null) { + contribution.chunkInfo = null + contribution.transferred = 0 + } + compositeDisposable.add( + uploadRepository + .checkDuplicateImage(contribution.localUriPath!!.path) + .subscribeOn(ioThreadScheduler) + .subscribe({ imageCheckResult: Int -> + if (imageCheckResult == IMAGE_OK) { + contribution.state = STATE_QUEUED + compositeDisposable.add( + contributionsRepository + .save(contribution) + .subscribeOn(ioThreadScheduler) + .doOnComplete { + restartUploads(contributionList, index + 1, context) + } + .subscribe { + makeOneTimeWorkRequest( + context, ExistingWorkPolicy.KEEP + ) + }) + } else { + Timber.e("Contribution already exists") + compositeDisposable.add( + contributionsRepository + .deleteContributionFromDB(contribution) + .subscribeOn(ioThreadScheduler).doOnComplete { + restartUploads(contributionList, index + 1, context) + } + .subscribe()) + } + }, { throwable: Throwable? -> + Timber.e(throwable) + restartUploads(contributionList, index + 1, context) + }) + ) + } else { + contribution.state = STATE_QUEUED + compositeDisposable.add( + contributionsRepository + .save(contribution) + .subscribeOn(ioThreadScheduler) + .doOnComplete { + restartUploads(contributionList, index + 1, context) + } + .subscribe { + makeOneTimeWorkRequest(context, ExistingWorkPolicy.KEEP) + } + ) + } + } + + /** + * Restarts the upload for the specified list of contributions for the given index. + * + * @param contributionList The list of contributions. + * @param index The index in the list which to be restarted. + * @param context The context in which the operation is being performed. + */ + fun restartUpload(contributionList: List, index: Int, context: Context) { + CommonsApplication.isPaused = false + if (index >= contributionList.size) { + return + } + val contribution = contributionList[index] + if (contribution.state == STATE_FAILED) { + contribution.dateUploadStarted = Calendar.getInstance().time + if (contribution.errorInfo == null) { + contribution.chunkInfo = null + contribution.transferred = 0 + } + compositeDisposable.add( + uploadRepository + .checkDuplicateImage(contribution.localUriPath!!.path) + .subscribeOn(ioThreadScheduler) + .subscribe { imageCheckResult: Int -> + if (imageCheckResult == IMAGE_OK) { + contribution.state = STATE_QUEUED + compositeDisposable.add( + contributionsRepository + .save(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe { + makeOneTimeWorkRequest(context, ExistingWorkPolicy.KEEP) + } + ) + } else { + Timber.e("Contribution already exists") + compositeDisposable.add( + contributionsRepository + .deleteContributionFromDB(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe() + ) + } + }) + } else { + contribution.state = STATE_QUEUED + compositeDisposable.add( + contributionsRepository + .save(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe { + makeOneTimeWorkRequest(context, ExistingWorkPolicy.KEEP) + } + ) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ReadFBMD.java b/app/src/main/java/fr/free/nrw/commons/upload/ReadFBMD.java deleted file mode 100644 index 586f9fc24..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/ReadFBMD.java +++ /dev/null @@ -1,48 +0,0 @@ -package fr.free.nrw.commons.upload; - -import fr.free.nrw.commons.utils.ImageUtils; -import io.reactivex.Single; -import java.io.FileInputStream; -import java.io.IOException; -import javax.inject.Inject; -import javax.inject.Singleton; - -/** - * We want to discourage users from uploading images to Commons that were taken from Facebook. This - * attempts to detect whether an image was downloaded from Facebook by heuristically searching for - * metadata that is specific to images that come from Facebook. - */ -@Singleton -public class ReadFBMD { - - @Inject - public ReadFBMD() { - } - - public Single processMetadata(String path) { - return Single.fromCallable(() -> { - try { - int psBlockOffset; - int fbmdOffset; - - try (FileInputStream fs = new FileInputStream(path)) { - byte[] bytes = new byte[4096]; - fs.read(bytes); - fs.close(); - String fileStr = new String(bytes); - psBlockOffset = fileStr.indexOf("8BIM"); - fbmdOffset = fileStr.indexOf("FBMD"); - } - - if (psBlockOffset > 0 && fbmdOffset > 0 - && fbmdOffset > psBlockOffset && fbmdOffset - psBlockOffset < 0x80) { - return ImageUtils.FILE_FBMD; - } - } catch (IOException e) { - e.printStackTrace(); - } - return ImageUtils.IMAGE_OK; - }); - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ReadFBMD.kt b/app/src/main/java/fr/free/nrw/commons/upload/ReadFBMD.kt new file mode 100644 index 000000000..e4b022826 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/ReadFBMD.kt @@ -0,0 +1,45 @@ +package fr.free.nrw.commons.upload + +import fr.free.nrw.commons.utils.ImageUtils.FILE_FBMD +import fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK +import io.reactivex.Single +import timber.log.Timber +import java.io.FileInputStream +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * We want to discourage users from uploading images to Commons that were taken from Facebook. This + * attempts to detect whether an image was downloaded from Facebook by heuristically searching for + * metadata that is specific to images that come from Facebook. + */ +@Singleton +class ReadFBMD @Inject constructor() { + fun processMetadata(path: String?): Single = Single.fromCallable { + var result = IMAGE_OK + try { + var psBlockOffset: Int + var fbmdOffset: Int + + FileInputStream(path).use { fs -> + val bytes = ByteArray(4096) + fs.read(bytes) + with(String(bytes)) { + psBlockOffset = indexOf("8BIM") + fbmdOffset = indexOf("FBMD") + } + } + + result = if (psBlockOffset > 0 && fbmdOffset > 0 && + fbmdOffset > psBlockOffset && + fbmdOffset - psBlockOffset < 0x80 + ) FILE_FBMD else IMAGE_OK + + } catch (e: IOException) { + Timber.e(e) + } + return@fromCallable result + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.java b/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.java deleted file mode 100644 index 50e6e5f27..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.java +++ /dev/null @@ -1,6 +0,0 @@ -package fr.free.nrw.commons.upload; - -public interface SimilarImageInterface { - void showSimilarImageFragment(String originalFilePath, String possibleFilePath, - ImageCoordinates similarImageCoordinates); -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.kt b/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.kt new file mode 100644 index 000000000..ae4805ea2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.kt @@ -0,0 +1,9 @@ +package fr.free.nrw.commons.upload + +interface SimilarImageInterface { + fun showSimilarImageFragment( + originalFilePath: String?, + possibleFilePath: String?, + similarImageCoordinates: ImageCoordinates? + ) +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailClickedListener.java b/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailClickedListener.java deleted file mode 100644 index 371e5ee10..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailClickedListener.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.upload; - -import fr.free.nrw.commons.filepicker.UploadableFile; - -public interface ThumbnailClickedListener { - void thumbnailClicked(UploadableFile content); -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java index eb180ec44..f48773180 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java @@ -1,8 +1,8 @@ package fr.free.nrw.commons.upload; import static fr.free.nrw.commons.contributions.ContributionController.ACTION_INTERNAL_UPLOADS; -import static fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE; import static fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction; +import static fr.free.nrw.commons.utils.PermissionUtils.getPERMISSIONS_STORAGE; import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; import static fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE; import static fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE_CATEGORY; @@ -32,7 +32,6 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; import androidx.work.ExistingWorkPolicy; -import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.LoginActivity; import fr.free.nrw.commons.auth.SessionManager; @@ -73,7 +72,8 @@ import javax.inject.Inject; import javax.inject.Named; import timber.log.Timber; -public class UploadActivity extends BaseActivity implements UploadContract.View, UploadBaseFragment.Callback, ThumbnailsAdapter.OnThumbnailDeletedListener { +public class UploadActivity extends BaseActivity implements + UploadContract.View, UploadBaseFragment.Callback, ThumbnailsAdapter.OnThumbnailDeletedListener { @Inject ContributionController contributionController; @@ -149,7 +149,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, @SuppressLint("CheckResult") @Override - protected void onCreate(Bundle savedInstanceState) { + protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding = ActivityUploadBinding.inflate(getLayoutInflater()); @@ -161,9 +161,9 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, */ if (savedInstanceState != null) { isFragmentsSaved = true; - List fragmentList = getSupportFragmentManager().getFragments(); + final List fragmentList = getSupportFragmentManager().getFragments(); fragments = new ArrayList<>(); - for (Fragment fragment : fragmentList) { + for (final Fragment fragment : fragmentList) { fragments.add((UploadBaseFragment) fragment); } } @@ -175,8 +175,8 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, nearbyPopupAnswers = new HashMap<>(); //getting the current dpi of the device and if it is less than 320dp i.e. overlapping //threshold, thumbnails automatically minimizes - DisplayMetrics metrics = getResources().getDisplayMetrics(); - float dpi = (metrics.widthPixels)/(metrics.density); + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + final float dpi = (metrics.widthPixels)/(metrics.density); if (dpi<=321) { onRlContainerTitleClicked(); } @@ -218,13 +218,13 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, binding.vpUpload.setAdapter(uploadImagesAdapter); binding.vpUpload.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { @Override - public void onPageScrolled(int position, float positionOffset, - int positionOffsetPixels) { + public void onPageScrolled(final int position, final float positionOffset, + final int positionOffsetPixels) { } @Override - public void onPageSelected(int position) { + public void onPageSelected(final int position) { currentSelectedPosition = position; if (position >= uploadableFiles.size()) { binding.cvContainerTopCard.setVisibility(View.GONE); @@ -236,7 +236,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } @Override - public void onPageScrollStateChanged(int state) { + public void onPageScrollStateChanged(final int state) { } }); @@ -271,13 +271,12 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, getString(R.string.block_notification_title), getString(R.string.block_notification), getString(R.string.ok), - this::finish, - true))); + this::finish))); } public void checkStoragePermissions() { // Check if all required permissions are granted - final boolean hasAllPermissions = PermissionUtils.hasPermission(this, PERMISSIONS_STORAGE); + final boolean hasAllPermissions = PermissionUtils.hasPermission(this, getPERMISSIONS_STORAGE()); final boolean hasPartialAccess = PermissionUtils.hasPartialAccess(this); if (hasAllPermissions || hasPartialAccess) { // All required permissions are granted, so enable UI elements and perform actions @@ -297,7 +296,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, }, R.string.storage_permission_title, R.string.write_storage_permission_rationale_for_image_share, - PERMISSIONS_STORAGE); + getPERMISSIONS_STORAGE()); } } /* If all permissions are not granted and a dialog is already showing on screen @@ -320,11 +319,19 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, finish(); } + /** + * go to the uploadProgress activity to check the status of uploading + */ + @Override + public void goToUploadProgressActivity() { + startActivity(new Intent(this, UploadProgressActivity.class)); + } + /** * Show/Hide the progress dialog */ @Override - public void showProgress(boolean shouldShow) { + public void showProgress(final boolean shouldShow) { if (shouldShow) { if (!progressDialog.isShowing()) { progressDialog.show(); @@ -337,7 +344,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } @Override - public int getIndexInViewFlipper(UploadBaseFragment fragment) { + public int getIndexInViewFlipper(final UploadBaseFragment fragment) { return fragments.indexOf(fragment); } @@ -352,7 +359,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } @Override - public void showMessage(int messageResourceId) { + public void showMessage(final int messageResourceId) { ViewUtil.showLongToast(this, messageResourceId); } @@ -362,12 +369,12 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } @Override - public void showHideTopCard(boolean shouldShow) { + public void showHideTopCard(final boolean shouldShow) { binding.llContainerTopCard.setVisibility(shouldShow ? View.VISIBLE : View.GONE); } @Override - public void onUploadMediaDeleted(int index) { + public void onUploadMediaDeleted(final int index) { fragments.remove(index);//Remove the corresponding fragment uploadableFiles.remove(index);//Remove the files from the list thumbnailsAdapter.notifyItemRemoved(index); //Notify the thumbnails adapter @@ -390,7 +397,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, public void askUserToLogIn() { Timber.d("current session is null, asking user to login"); ViewUtil.showLongToast(this, getString(R.string.user_not_logged_in)); - Intent loginIntent = new Intent(UploadActivity.this, LoginActivity.class); + final Intent loginIntent = new Intent(UploadActivity.this, LoginActivity.class); startActivity(loginIntent); } @@ -402,25 +409,23 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, if (requestCode == RequestCodes.STORAGE) { if (VERSION.SDK_INT >= VERSION_CODES.M) { for (int i = 0; i < grantResults.length; i++) { - String permission = permissions[i]; + final String permission = permissions[i]; areAllGranted = grantResults[i] == PackageManager.PERMISSION_GRANTED; if (grantResults[i] == PackageManager.PERMISSION_DENIED) { - boolean showRationale = shouldShowRequestPermissionRationale(permission); + final boolean showRationale = shouldShowRequestPermissionRationale(permission); if (!showRationale) { DialogUtil.showAlertDialog(this, getString(R.string.storage_permissions_denied), getString(R.string.unable_to_share_upload_item), getString(android.R.string.ok), - this::finish, - false); + this::finish); } else { DialogUtil.showAlertDialog(this, getString(R.string.storage_permission_title), getString( R.string.write_storage_permission_rationale_for_image_share), getString(android.R.string.ok), - this::checkStoragePermissions, - false); + this::checkStoragePermissions); } } } @@ -433,27 +438,19 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, super.onRequestPermissionsResult(requestCode, permissions, grantResults); } - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == CommonsApplication.OPEN_APPLICATION_DETAIL_SETTINGS) { - //TODO: Confirm if handling manual permission enabled is required - } - } - /** * Sets the flag indicating whether the upload is of a specific place. * * @param uploadOfAPlace a boolean value indicating whether the upload is of place. */ - public static void setUploadIsOfAPlace(boolean uploadOfAPlace) { + public static void setUploadIsOfAPlace(final boolean uploadOfAPlace) { uploadIsOfAPlace = uploadOfAPlace; } private void receiveSharedItems() { - thumbnailsAdapter.context=this; - Intent intent = getIntent(); - String action = intent.getAction(); + ThumbnailsAdapter.context=this; + final Intent intent = getIntent(); + final String action = intent.getAction(); if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { receiveExternalSharedItems(); } else if (ACTION_INTERNAL_UPLOADS.equals(action)) { @@ -485,8 +482,8 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } - for (UploadableFile uploadableFile : uploadableFiles) { - UploadMediaDetailFragment uploadMediaDetailFragment = new UploadMediaDetailFragment(); + for (final UploadableFile uploadableFile : uploadableFiles) { + final UploadMediaDetailFragment uploadMediaDetailFragment = new UploadMediaDetailFragment(); if (!uploadIsOfAPlace) { handleLocation(); @@ -496,9 +493,9 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, uploadMediaDetailFragment.setImageToBeUploaded(uploadableFile, place, currLocation); } - UploadMediaDetailFragmentCallback uploadMediaDetailFragmentCallback = new UploadMediaDetailFragmentCallback() { + final UploadMediaDetailFragmentCallback uploadMediaDetailFragmentCallback = new UploadMediaDetailFragmentCallback() { @Override - public void deletePictureAtIndex(int index) { + public void deletePictureAtIndex(final int index) { store.putInt(keyForCurrentUploadImagesSize, (store.getInt(keyForCurrentUploadImagesSize) - 1)); presenter.deletePictureAtIndex(index); @@ -515,29 +512,29 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, * @param filepath The file path of the new thumbnail image. */ @Override - public void changeThumbnail(int index, String filepath) { + public void changeThumbnail(final int index, final String filepath) { uploadableFiles.remove(index); uploadableFiles.add(index, new UploadableFile(new File(filepath))); binding.rvThumbnails.getAdapter().notifyDataSetChanged(); } @Override - public void onNextButtonClicked(int index) { + public void onNextButtonClicked(final int index) { UploadActivity.this.onNextButtonClicked(index); } @Override - public void onPreviousButtonClicked(int index) { + public void onPreviousButtonClicked(final int index) { UploadActivity.this.onPreviousButtonClicked(index); } @Override - public void showProgress(boolean shouldShow) { + public void showProgress(final boolean shouldShow) { UploadActivity.this.showProgress(shouldShow); } @Override - public int getIndexInViewFlipper(UploadBaseFragment fragment) { + public int getIndexInViewFlipper(final UploadBaseFragment fragment) { return fragments.indexOf(fragment); } @@ -553,7 +550,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, }; if(isFragmentsSaved){ - UploadMediaDetailFragment fragment = (UploadMediaDetailFragment) fragments.get(0); + final UploadMediaDetailFragment fragment = (UploadMediaDetailFragment) fragments.get(0); fragment.setCallback(uploadMediaDetailFragmentCallback); }else{ uploadMediaDetailFragment.setCallback(uploadMediaDetailFragmentCallback); @@ -566,7 +563,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, if(!isFragmentsSaved){ uploadCategoriesFragment = new UploadCategoriesFragment(); if (place != null) { - Bundle categoryBundle = new Bundle(); + final Bundle categoryBundle = new Bundle(); categoryBundle.putString(SELECTED_NEARBY_PLACE_CATEGORY, place.getCategory()); uploadCategoriesFragment.setArguments(categoryBundle); } @@ -574,7 +571,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, uploadCategoriesFragment.setCallback(this); depictsFragment = new DepictsFragment(); - Bundle placeBundle = new Bundle(); + final Bundle placeBundle = new Bundle(); placeBundle.putParcelable(SELECTED_NEARBY_PLACE, place); depictsFragment.setArguments(placeBundle); depictsFragment.setCallback(this); @@ -590,7 +587,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, for(int i=1;i prefExifTags = defaultKvStore.getStringSet(Prefs.MANAGED_EXIF_TAGS); + final Set prefExifTags = defaultKvStore.getStringSet(Prefs.MANAGED_EXIF_TAGS); if (prefExifTags.contains(getString(R.string.exif_tag_location))) { return false; } @@ -670,7 +666,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, * @param maxSize Max size of the {@code uploadableFiles} */ @Override - public void highlightNextImageOnCancelledImage(int index, int maxSize) { + public void highlightNextImageOnCancelledImage(final int index, final int maxSize) { if (binding.vpUpload != null && index < (maxSize)) { binding.vpUpload.setCurrentItem(index + 1, false); binding.vpUpload.setCurrentItem(index, false); @@ -685,8 +681,8 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, * @param isCancelled Is true when user has cancelled upload of any image in current upload */ @Override - public void setImageCancelled(boolean isCancelled) { - BasicKvStore basicKvStore = new BasicKvStore(this,"IsAnyImageCancelled"); + public void setImageCancelled(final boolean isCancelled) { + final BasicKvStore basicKvStore = new BasicKvStore(this,"IsAnyImageCancelled"); basicKvStore.putBoolean("IsAnyImageCancelled", isCancelled); } @@ -694,15 +690,12 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, * Calculate the difference between current location and * location recorded before capturing the image * - * @param currLocation - * @param prevLocation - * @return */ - private float getLocationDifference(LatLng currLocation, LatLng prevLocation) { + private float getLocationDifference(final LatLng currLocation, final LatLng prevLocation) { if (prevLocation == null) { return 0.0f; } - float[] distance = new float[2]; + final float[] distance = new float[2]; Location.distanceBetween( currLocation.getLatitude(), currLocation.getLongitude(), prevLocation.getLatitude(), prevLocation.getLongitude(), distance); @@ -714,7 +707,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } private void receiveInternalSharedItems() { - Intent intent = getIntent(); + final Intent intent = getIntent(); Timber.d("Received intent %s with action %s", intent.toString(), intent.getAction()); @@ -750,17 +743,16 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, @Override - public void showAlertDialog(int messageResourceId, Runnable onPositiveClick) { + public void showAlertDialog(final int messageResourceId, @NonNull final Runnable onPositiveClick) { DialogUtil.showAlertDialog(this, "", getString(messageResourceId), getString(R.string.ok), - onPositiveClick, - false); + onPositiveClick); } @Override - public void onNextButtonClicked(int index) { + public void onNextButtonClicked(final int index) { if (index < fragments.size() - 1) { binding.vpUpload.setCurrentItem(index + 1, false); fragments.get(index + 1).onBecameVisible(); @@ -776,7 +768,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } @Override - public void onPreviousButtonClicked(int index) { + public void onPreviousButtonClicked(final int index) { if (index != 0) { binding.vpUpload.setCurrentItem(index - 1, true); fragments.get(index - 1).onBecameVisible(); @@ -791,7 +783,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } @Override - public void onThumbnailDeleted(int position) { + public void onThumbnailDeleted(final int position) { presenter.deletePictureAtIndex(position); } @@ -800,21 +792,22 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, */ - private class UploadImageAdapter extends FragmentStatePagerAdapter { + private static class UploadImageAdapter extends FragmentStatePagerAdapter { List fragments; - public UploadImageAdapter(FragmentManager fragmentManager) { + public UploadImageAdapter(final FragmentManager fragmentManager) { super(fragmentManager); this.fragments = new ArrayList<>(); } - public void setFragments(List fragments) { + public void setFragments(final List fragments) { this.fragments = fragments; notifyDataSetChanged(); } + @NonNull @Override - public Fragment getItem(int position) { + public Fragment getItem(final int position) { return fragments.get(position); } @@ -824,7 +817,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } @Override - public int getItemPosition(Object object) { + public int getItemPosition(@NonNull final Object item) { return PagerAdapter.POSITION_NONE; } } @@ -898,11 +891,11 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, private void showAlertDialogForCategories() { UploadMediaPresenter.isCategoriesDialogShowing = true; // Inflate the custom layout - LayoutInflater inflater = getLayoutInflater(); - View view = inflater.inflate(R.layout.activity_upload_categories_dialog, null); - CheckBox checkBox = view.findViewById(R.id.categories_checkbox); + final LayoutInflater inflater = getLayoutInflater(); + final View view = inflater.inflate(R.layout.activity_upload_categories_dialog, null); + final CheckBox checkBox = view.findViewById(R.id.categories_checkbox); // Create the alert dialog - AlertDialog alertDialog = new AlertDialog.Builder(this) + final AlertDialog alertDialog = new AlertDialog.Builder(this) .setView(view) .setTitle(getString(R.string.multiple_files_depiction_header)) .setMessage(getString(R.string.multiple_files_depiction)) @@ -948,7 +941,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, it shows a list of all the apps on the device and allows users to turn battery optimisation off. */ - Intent batteryOptimisationSettingsIntent = new Intent( + final Intent batteryOptimisationSettingsIntent = new Intent( Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); startActivity(batteryOptimisationSettingsIntent); // calling checkImageQuality after battery dialog is interacted with @@ -969,15 +962,15 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, * conditions are met, returns current location of the user. */ private void handleLocation(){ - LocationPermissionsHelper locationPermissionsHelper = new LocationPermissionsHelper( + final LocationPermissionsHelper locationPermissionsHelper = new LocationPermissionsHelper( this, locationManager, null); if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { currLocation = locationManager.getLastLocation(); } if (currLocation != null) { - float locationDifference = getLocationDifference(currLocation, prevLocation); - boolean isLocationTagUnchecked = isLocationTagUncheckedInTheSettings(); + final float locationDifference = getLocationDifference(currLocation, prevLocation); + final boolean isLocationTagUnchecked = isLocationTagUncheckedInTheSettings(); /* Remove location if the user has unchecked the Location EXIF tag in the Manage EXIF Tags setting or turned "Record location for in-app shots" off. Also, location information is discarded if the difference between diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.kt index 54f9b112f..754ae05dd 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.kt @@ -7,7 +7,9 @@ import fr.free.nrw.commons.auth.csrf.CsrfTokenClient import fr.free.nrw.commons.contributions.ChunkInfo import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.contributions.ContributionDao +import fr.free.nrw.commons.di.NetworkingModule import fr.free.nrw.commons.upload.worker.UploadWorker.NotificationUpdateProgressListener +import fr.free.nrw.commons.utils.TimeProvider import fr.free.nrw.commons.wikidata.mwapi.MwException import io.reactivex.Observable import io.reactivex.disposables.CompositeDisposable @@ -26,6 +28,7 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton @Singleton @@ -33,7 +36,7 @@ class UploadClient @Inject constructor( private val uploadInterface: UploadInterface, - private val csrfTokenClient: CsrfTokenClient, + @Named(NetworkingModule.NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, private val pageContentsCreator: PageContentsCreator, private val fileUtilsWrapper: FileUtilsWrapper, private val gson: Gson, @@ -66,7 +69,7 @@ class UploadClient val file = contribution.localUriPath val fileChunks = fileUtilsWrapper.getFileChunks(file, chunkSize) - val mediaType = fileUtilsWrapper.getMimeType(file).toMediaTypeOrNull() + val mediaType = fileUtilsWrapper.getMimeType(file)?.toMediaTypeOrNull() val chunkInfo = AtomicReference() if (isStashValid(contribution)) { @@ -270,7 +273,7 @@ class UploadClient if (uploadResult.upload == null) { val exception = gson.fromJson(uploadResponse, MwException::class.java) Timber.e(exception, "Error in uploading file from stash") - throw Exception(exception.getErrorCode()) + throw Exception(exception.errorCode) } uploadResult.upload } @@ -278,11 +281,7 @@ class UploadClient Timber.e(throwable, "Exception occurred in uploading file from stash") Observable.error(throwable) } - - fun interface TimeProvider { - fun currentTimeMillis(): Long - } - } +} private fun canProcess( contributionDao: ContributionDao, diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.java deleted file mode 100644 index 4df778746..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.java +++ /dev/null @@ -1,75 +0,0 @@ -package fr.free.nrw.commons.upload; - -import java.util.List; - -import fr.free.nrw.commons.BasePresenter; -import fr.free.nrw.commons.filepicker.UploadableFile; - -/** - * The contract using which the UplaodActivity would communicate with its presenter - */ -public interface UploadContract { - - public interface View { - - boolean isLoggedIn(); - - void finish(); - - void returnToMainActivity(); - - void askUserToLogIn(); - - /** - * Changes current image when one image upload is cancelled, to highlight next image in the top thumbnail. - * Fixes: Issue - * - * @param index Index of image to be removed - * @param maxSize Max size of the {@code uploadableFiles} - */ - void highlightNextImageOnCancelledImage(int index, int maxSize); - - /** - * Used to check if user has cancelled upload of any image in current upload - * so that location compare doesn't show up again in same upload. - * Fixes: Issue - * - * @param isCancelled Is true when user has cancelled upload of any image in current upload - */ - void setImageCancelled(boolean isCancelled); - - void showProgress(boolean shouldShow); - - void showMessage(int messageResourceId); - - /** - * Displays an alert with message given by {@code messageResourceId}. - * {@code onPositiveClick} is run after acknowledgement. - */ - void showAlertDialog(int messageResourceId, Runnable onPositiveClick); - - List getUploadableFiles(); - - void showHideTopCard(boolean shouldShow); - - void onUploadMediaDeleted(int index); - - void updateTopCardTitle(); - - void makeUploadRequest(); - } - - public interface UserActionListener extends BasePresenter { - - void handleSubmit(); - - void deletePictureAtIndex(int index); - - /** - * Calls checkImageQuality of UploadMediaPresenter to check image quality of next image - * - * @param uploadItemIndex Index of next image, whose quality is to be checked - */ - void checkImageQuality(int uploadItemIndex); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.kt new file mode 100644 index 000000000..04ab02b8e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.kt @@ -0,0 +1,77 @@ +package fr.free.nrw.commons.upload + +import fr.free.nrw.commons.BasePresenter +import fr.free.nrw.commons.filepicker.UploadableFile + +/** + * The contract using which the UplaodActivity would communicate with its presenter + */ +interface UploadContract { + interface View { + fun isLoggedIn(): Boolean + + fun finish() + + fun returnToMainActivity() + + /** + * When submission successful, go to the loadProgressActivity to hint the user this + * submission is valid. And the user will see the upload progress in this activity; + * Fixes: [Issue](https://github.com/commons-app/apps-android-commons/issues/5846) + */ + fun goToUploadProgressActivity() + + fun askUserToLogIn() + + /** + * Changes current image when one image upload is cancelled, to highlight next image in the top thumbnail. + * Fixes: [Issue](https://github.com/commons-app/apps-android-commons/issues/5511) + * + * @param index Index of image to be removed + * @param maxSize Max size of the `uploadableFiles` + */ + fun highlightNextImageOnCancelledImage(index: Int, maxSize: Int) + + /** + * Used to check if user has cancelled upload of any image in current upload + * so that location compare doesn't show up again in same upload. + * Fixes: [Issue](https://github.com/commons-app/apps-android-commons/issues/5511) + * + * @param isCancelled Is true when user has cancelled upload of any image in current upload + */ + fun setImageCancelled(isCancelled: Boolean) + + fun showProgress(shouldShow: Boolean) + + fun showMessage(messageResourceId: Int) + + /** + * Displays an alert with message given by `messageResourceId`. + * `onPositiveClick` is run after acknowledgement. + */ + fun showAlertDialog(messageResourceId: Int, onPositiveClick: Runnable) + + fun getUploadableFiles(): List? + + fun showHideTopCard(shouldShow: Boolean) + + fun onUploadMediaDeleted(index: Int) + + fun updateTopCardTitle() + + fun makeUploadRequest() + } + + interface UserActionListener : BasePresenter { + fun handleSubmit() + + fun deletePictureAtIndex(index: Int) + + /** + * Calls checkImageQuality of UploadMediaPresenter to check image quality of next image + * + * @param uploadItemIndex Index of next image, whose quality is to be checked + */ + fun checkImageQuality(uploadItemIndex: Int) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java index a4e0d1029..43331193e 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java @@ -20,8 +20,8 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ListView; import android.widget.TextView; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.RecyclerView; @@ -57,27 +57,29 @@ public class UploadMediaDetailAdapter extends private int currentPosition; private Fragment fragment; private Activity activity; + private final ActivityResultLauncher voiceInputResultLauncher; private SelectedVoiceIcon selectedVoiceIcon; - private static final int REQUEST_CODE_FOR_VOICE_INPUT = 1213; private RowItemDescriptionBinding binding; public UploadMediaDetailAdapter(Fragment fragment, String savedLanguageValue, - RecentLanguagesDao recentLanguagesDao) { + RecentLanguagesDao recentLanguagesDao, ActivityResultLauncher voiceInputResultLauncher) { uploadMediaDetails = new ArrayList<>(); selectedLanguages = new HashMap<>(); this.savedLanguageValue = savedLanguageValue; this.recentLanguagesDao = recentLanguagesDao; this.fragment = fragment; + this.voiceInputResultLauncher = voiceInputResultLauncher; } public UploadMediaDetailAdapter(Activity activity, final String savedLanguageValue, - List uploadMediaDetails, RecentLanguagesDao recentLanguagesDao) { + List uploadMediaDetails, RecentLanguagesDao recentLanguagesDao, ActivityResultLauncher voiceInputResultLauncher) { this.uploadMediaDetails = uploadMediaDetails; selectedLanguages = new HashMap<>(); this.savedLanguageValue = savedLanguageValue; this.recentLanguagesDao = recentLanguagesDao; this.activity = activity; + this.voiceInputResultLauncher = voiceInputResultLauncher; } public void setCallback(Callback callback) { @@ -150,11 +152,7 @@ public class UploadMediaDetailAdapter extends ); try { - if (activity == null) { - fragment.startActivityForResult(intent, REQUEST_CODE_FOR_VOICE_INPUT); - } else { - activity.startActivityForResult(intent, REQUEST_CODE_FOR_VOICE_INPUT); - } + voiceInputResultLauncher.launch(intent); } catch (Exception e) { Timber.e(e.getMessage()); } @@ -348,7 +346,7 @@ public class UploadMediaDetailAdapter extends public void onClick(View view) { Dialog dialog = new Dialog(view.getContext()); dialog.setContentView(R.layout.dialog_select_language); - dialog.setCanceledOnTouchOutside(true); + dialog.setCancelable(false); dialog.getWindow().setLayout( (int) (view.getContext().getResources().getDisplayMetrics().widthPixels * 0.90), @@ -407,7 +405,7 @@ public class UploadMediaDetailAdapter extends recentLanguagesDao .addRecentLanguage(new Language(languageName, languageCode)); - selectedLanguages.remove(position); + selectedLanguages.clear(); selectedLanguages.put(position, languageCode); ((LanguagesAdapter) adapterView .getAdapter()).setSelectedLangCode(languageCode); @@ -497,7 +495,7 @@ public class UploadMediaDetailAdapter extends } recentLanguagesDao.addRecentLanguage(new Language(languageName, languageCode)); - selectedLanguages.remove(position); + selectedLanguages.clear(); selectedLanguages.put(position, languageCode); ((RecentLanguagesAdapter) adapterView .getAdapter()).setSelectedLangCode(languageCode); diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java index 2611645de..dfb7ae794 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java @@ -187,7 +187,7 @@ public class UploadModel { public Observable buildContributions() { return Observable.fromIterable(items).map(item -> { - String imageSHA1 = FileUtils.getSHA1(context.getContentResolver().openInputStream(item.getContentUri())); + String imageSHA1 = FileUtils.INSTANCE.getSHA1(context.getContentResolver().openInputStream(item.getContentUri())); final Contribution contribution = new Contribution( item, sessionManager, newListOf(selectedDepictions), newListOf(selectedCategories), imageSHA1); diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadModule.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadModule.java deleted file mode 100644 index eccdff333..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadModule.java +++ /dev/null @@ -1,58 +0,0 @@ -package fr.free.nrw.commons.upload; - -import com.google.gson.Gson; -import dagger.Binds; -import dagger.Module; -import dagger.Provides; -import fr.free.nrw.commons.auth.csrf.CsrfTokenClient; -import fr.free.nrw.commons.contributions.ContributionDao; -import fr.free.nrw.commons.di.NetworkingModule; -import fr.free.nrw.commons.upload.categories.CategoriesContract; -import fr.free.nrw.commons.upload.categories.CategoriesPresenter; -import fr.free.nrw.commons.upload.depicts.DepictsContract; -import fr.free.nrw.commons.upload.depicts.DepictsPresenter; -import fr.free.nrw.commons.upload.license.MediaLicenseContract; -import fr.free.nrw.commons.upload.license.MediaLicensePresenter; -import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract; -import fr.free.nrw.commons.upload.mediaDetails.UploadMediaPresenter; -import javax.inject.Named; - -/** - * The Dagger Module for upload related presenters and (some other objects maybe in future) - */ -@Module -public abstract class UploadModule { - - @Binds - public abstract UploadContract.UserActionListener bindHomePresenter(UploadPresenter - presenter); - - @Binds - public abstract CategoriesContract.UserActionListener bindsCategoriesPresenter( - CategoriesPresenter presenter); - - @Binds - public abstract MediaLicenseContract.UserActionListener bindsMediaLicensePresenter( - MediaLicensePresenter - presenter); - - @Binds - public abstract UploadMediaDetailsContract.UserActionListener bindsUploadMediaPresenter( - UploadMediaPresenter - presenter); - - @Binds - public abstract DepictsContract.UserActionListener bindsDepictsPresenter( - DepictsPresenter - presenter - ); - - @Provides - public static UploadClient provideUploadClient(final UploadInterface uploadInterface, - @Named(NetworkingModule.NAMED_COMMONS_CSRF) final CsrfTokenClient csrfTokenClient, - final PageContentsCreator pageContentsCreator, final FileUtilsWrapper fileUtilsWrapper, - final Gson gson, final ContributionDao contributionDao) { - return new UploadClient(uploadInterface, csrfTokenClient, pageContentsCreator, - fileUtilsWrapper, gson, System::currentTimeMillis, contributionDao); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadModule.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadModule.kt new file mode 100644 index 000000000..9596391c6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadModule.kt @@ -0,0 +1,33 @@ +package fr.free.nrw.commons.upload + +import dagger.Binds +import dagger.Module +import fr.free.nrw.commons.upload.categories.CategoriesContract +import fr.free.nrw.commons.upload.categories.CategoriesPresenter +import fr.free.nrw.commons.upload.depicts.DepictsContract +import fr.free.nrw.commons.upload.depicts.DepictsPresenter +import fr.free.nrw.commons.upload.license.MediaLicenseContract +import fr.free.nrw.commons.upload.license.MediaLicensePresenter +import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract +import fr.free.nrw.commons.upload.mediaDetails.UploadMediaPresenter + +/** + * The Dagger Module for upload related presenters and (some other objects maybe in future) + */ +@Module +abstract class UploadModule { + @Binds + abstract fun bindHomePresenter(presenter: UploadPresenter): UploadContract.UserActionListener + + @Binds + abstract fun bindsCategoriesPresenter(presenter: CategoriesPresenter): CategoriesContract.UserActionListener + + @Binds + abstract fun bindsMediaLicensePresenter(presenter: MediaLicensePresenter): MediaLicenseContract.UserActionListener + + @Binds + abstract fun bindsUploadMediaPresenter(presenter: UploadMediaPresenter): UploadMediaDetailsContract.UserActionListener + + @Binds + abstract fun bindsDepictsPresenter(presenter: DepictsPresenter): DepictsContract.UserActionListener +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java index 144859432..093412c25 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java @@ -123,6 +123,9 @@ public class UploadPresenter implements UploadContract.UserActionListener { view.returnToMainActivity(); compositeDisposable.clear(); Timber.e("failed to upload: " + e.getMessage()); + + //is submission error, not need to go to the uploadActivity + //not start the uploading progress } @Override @@ -131,6 +134,10 @@ public class UploadPresenter implements UploadContract.UserActionListener { repository.cleanup(); view.returnToMainActivity(); compositeDisposable.clear(); + + //after finish the uploadActivity, if successful, + //directly go to the upload progress activity + view.goToUploadProgressActivity(); } }); } else { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailsAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailsAdapterFactory.java deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadView.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadView.java deleted file mode 100644 index 83b8fdab3..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadView.java +++ /dev/null @@ -1,88 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.net.Uri; - -import androidx.annotation.IntDef; - -import java.lang.annotation.Retention; -import java.util.List; - -import fr.free.nrw.commons.location.LatLng; - -import static java.lang.annotation.RetentionPolicy.SOURCE; - -public interface UploadView { - // Dummy implementation of the view interface to allow us to have a 'null object pattern' - // in the presenter and avoid constant NULL checking. -// UploadView DUMMY = (UploadView) Proxy.newProxyInstance(UploadView.class.getClassLoader(), -// new Class[]{UploadView.class}, (proxy, method, methodArgs) -> null); - - - @Retention(SOURCE) - @IntDef({PLEASE_WAIT, TITLE_CARD, CATEGORIES, LICENSE}) - @interface UploadPage {} - - int PLEASE_WAIT = 0; - - int TITLE_CARD = 1; - int CATEGORIES = 2; - int LICENSE = 3; - - boolean checkIfLoggedIn(); - - void updateThumbnails(List uploads); - - void setNextEnabled(boolean available); - - void setSubmitEnabled(boolean available); - - void setPreviousEnabled(boolean available); - - void setTopCardState(boolean state); - - void setRightCardVisibility(boolean visible); - - void setBottomCardState(boolean state); - - void setBackground(Uri mediaUri); - - void setTopCardVisibility(boolean visible); - - void setBottomCardVisibility(boolean visible); - - void setBottomCardVisibility(@UploadPage int page, int uploadCount); - - void updateRightCardContent(boolean gpsPresent); - - void updateBottomCardContent(int currentStep, int stepCount, UploadItem uploadItem, boolean isShowingItem); - - void updateLicenses(List licenses, String selectedLicense); - - void updateLicenseSummary(String selectedLicense, int imageCount); - - void updateTopCardContent(); - - void updateSubtitleVisibility(int imageCount); - - void dismissKeyboard(); - - void showBadPicturePopup(String errorMessage); - - void showDuplicatePicturePopup(); - - void finish(); - - void launchMapActivity(LatLng decCoords); - - void showErrorMessage(int resourceId); - - void initDefaultCategories(); - - void showNoCategorySelectedWarning(); - - void showProgressDialog(); - - void hideProgressDialog(); - - void askUserToLogIn(); -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/BaseDelegateAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/categories/BaseDelegateAdapter.kt index f1e4917a0..ce12d3915 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/BaseDelegateAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/BaseDelegateAdapter.kt @@ -10,15 +10,13 @@ abstract class BaseDelegateAdapter( areContentsTheSame: (T, T) -> Boolean = { old, new -> old == new }, ) : AsyncListDifferDelegationAdapter( object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: T, - newItem: T, - ) = areItemsTheSame(oldItem, newItem) + override fun areItemsTheSame(oldItem: T & Any, newItem: T & Any): Boolean { + return areItemsTheSame(oldItem, newItem) + } - override fun areContentsTheSame( - oldItem: T, - newItem: T, - ) = areContentsTheSame(oldItem, newItem) + override fun areContentsTheSame(oldItem: T & Any, newItem: T & Any): Boolean { + return areContentsTheSame(oldItem, newItem) + } }, *delegates, ) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.java b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.java deleted file mode 100644 index dc53e1a93..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.java +++ /dev/null @@ -1,97 +0,0 @@ -package fr.free.nrw.commons.upload.categories; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import fr.free.nrw.commons.BasePresenter; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.category.CategoryItem; -import java.util.List; - -/** - * The contract with with UploadCategoriesFragment and its presenter would talk to each other - */ -public interface CategoriesContract { - - interface View { - - void showProgress(boolean shouldShow); - - void showError(String error); - - void showError(int stringResourceId); - - void setCategories(List categories); - - void goToNextScreen(); - - void showNoCategorySelected(); - - /** - * Gets existing category names from media - */ - List getExistingCategories(); - - /** - * Returns required context - */ - Context getFragmentContext(); - - /** - * Returns to previous fragment - */ - void goBackToPreviousScreen(); - - /** - * Shows the progress dialog - */ - void showProgressDialog(); - - /** - * Hides the progress dialog - */ - void dismissProgressDialog(); - - /** - * Refreshes the categories - */ - void refreshCategories(); - - - /** - * Navigate the user to Login Activity - */ - void navigateToLoginScreen(); - } - - interface UserActionListener extends BasePresenter { - - void searchForCategories(String query); - - void verifyCategories(); - - void onCategoryItemClicked(CategoryItem categoryItem); - - /** - * Attaches view and media - */ - void onAttachViewWithMedia(@NonNull CategoriesContract.View view, Media media); - - /** - * Clears previous selections - */ - void clearPreviousSelection(); - - /** - * Update the categories - */ - void updateCategories(Media media, String wikiText); - - LiveData> getCategories(); - - void selectCategories(); - - } - - -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.kt b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.kt new file mode 100644 index 000000000..183c7cd93 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.kt @@ -0,0 +1,88 @@ +package fr.free.nrw.commons.upload.categories + +import android.content.Context +import androidx.lifecycle.LiveData +import fr.free.nrw.commons.BasePresenter +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.category.CategoryItem + +/** + * The contract with with UploadCategoriesFragment and its presenter would talk to each other + */ +interface CategoriesContract { + interface View { + fun showProgress(shouldShow: Boolean) + + fun showError(error: String?) + + fun showError(stringResourceId: Int) + + fun setCategories(categories: List?) + + fun goToNextScreen() + + fun showNoCategorySelected() + + /** + * Gets existing category names from media + */ + fun getExistingCategories(): List? + + /** + * Returns required context + */ + fun getFragmentContext(): Context + + /** + * Returns to previous fragment + */ + fun goBackToPreviousScreen() + + /** + * Shows the progress dialog + */ + fun showProgressDialog() + + /** + * Hides the progress dialog + */ + fun dismissProgressDialog() + + /** + * Refreshes the categories + */ + fun refreshCategories() + + /** + * Navigate the user to Login Activity + */ + fun navigateToLoginScreen() + } + + interface UserActionListener : BasePresenter { + fun searchForCategories(query: String) + + fun verifyCategories() + + fun onCategoryItemClicked(categoryItem: CategoryItem) + + /** + * Attaches view and media + */ + fun onAttachViewWithMedia(view: View, media: Media) + + /** + * Clears previous selections + */ + fun clearPreviousSelection() + + /** + * Update the categories + */ + fun updateCategories(media: Media, wikiText: String) + + fun getCategories(): LiveData> + + fun selectCategories() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.kt index 1822df830..dbeeae6ff 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.kt @@ -8,7 +8,8 @@ import fr.free.nrw.commons.R import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException import fr.free.nrw.commons.category.CategoryEditHelper import fr.free.nrw.commons.category.CategoryItem -import fr.free.nrw.commons.di.CommonsApplicationModule +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.repository.UploadRepository import fr.free.nrw.commons.upload.depicts.proxy import io.reactivex.Observable @@ -30,8 +31,8 @@ class CategoriesPresenter @Inject constructor( private val repository: UploadRepository, - @param:Named(CommonsApplicationModule.IO_THREAD) private val ioScheduler: Scheduler, - @param:Named(CommonsApplicationModule.MAIN_THREAD) private val mainThreadScheduler: Scheduler, + @param:Named(IO_THREAD) private val ioScheduler: Scheduler, + @param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler, ) : CategoriesContract.UserActionListener { companion object { private val DUMMY: CategoriesContract.View = proxy() @@ -61,7 +62,7 @@ class CategoriesPresenter .doOnNext { view.showProgress(true) }.switchMap(::searchResults) - .map { repository.selectedCategories + it } + .map { repository.getSelectedCategories() + it } .map { it.distinctBy { categoryItem -> categoryItem.name } } .observeOn(mainThreadScheduler) .subscribe( @@ -89,7 +90,7 @@ class CategoriesPresenter private fun searchResults(term: String): Observable>? { if (media == null) { return repository - .searchAll(term, getImageTitleList(), repository.selectedDepictions) + .searchAll(term, getImageTitleList(), repository.getSelectedDepictions()) .subscribeOn(ioScheduler) .map { it.filter { categoryItem -> @@ -101,13 +102,13 @@ class CategoriesPresenter return Observable .zip( repository - .getCategories(repository.selectedExistingCategories) + .getCategories(repository.getSelectedExistingCategories()) .map { list -> list.map { CategoryItem(it.name, it.description, it.thumbnail, true) } }, - repository.searchAll(term, getImageTitleList(), repository.selectedDepictions), + repository.searchAll(term, getImageTitleList(), repository.getSelectedDepictions()), ) { it1, it2 -> it1 + it2 }.subscribeOn(ioScheduler) @@ -138,7 +139,7 @@ class CategoriesPresenter * @return */ private fun getImageTitleList(): List = - repository.uploads + repository.getUploads() .map { it.uploadMediaDetails[0].captionText } .filterNot { TextUtils.isEmpty(it) } @@ -146,7 +147,7 @@ class CategoriesPresenter * Verifies the number of categories selected, prompts the user if none selected */ override fun verifyCategories() { - val selectedCategories = repository.selectedCategories + val selectedCategories = repository.getSelectedCategories() if (selectedCategories.isNotEmpty()) { repository.setSelectedCategories(selectedCategories.map { it.name }) view.goToNextScreen() @@ -173,14 +174,14 @@ class CategoriesPresenter ) { this.view = view this.media = media - repository.selectedExistingCategories = view.existingCategories + repository.setSelectedExistingCategories(view.getExistingCategories() ?: emptyList()) compositeDisposable.add( searchTerms .observeOn(mainThreadScheduler) .doOnNext { view.showProgress(true) }.switchMap(::searchResults) - .map { repository.selectedCategories + it } + .map { repository.getSelectedCategories() + it } .map { it.distinctBy { categoryItem -> categoryItem.name } } .observeOn(mainThreadScheduler) .subscribe( @@ -218,13 +219,21 @@ class CategoriesPresenter wikiText: String, ) { // check if view.existingCategories is null - if (repository.selectedCategories.isNotEmpty() || - (view.existingCategories != null && repository.selectedExistingCategories.size != view.existingCategories.size) + if ( + repository.getSelectedCategories().isNotEmpty() + || + ( + view.getExistingCategories() != null + && + repository.getSelectedExistingCategories().size + != + view.getExistingCategories()?.size + ) ) { val selectedCategories: MutableList = ( - repository.selectedCategories.map { it.name }.toMutableList() + - repository.selectedExistingCategories + repository.getSelectedCategories().map { it.name }.toMutableList() + + repository.getSelectedExistingCategories() ).toMutableList() if (selectedCategories.isNotEmpty()) { @@ -234,7 +243,7 @@ class CategoriesPresenter compositeDisposable.add( categoryEditHelper .makeCategoryEdit( - view.fragmentContext, + view.getFragmentContext(), media, selectedCategories, wikiText, @@ -305,7 +314,7 @@ class CategoriesPresenter override fun selectCategories() { compositeDisposable.add( - repository.placeCategories + repository.getPlaceCategories() .subscribeOn(ioScheduler) .observeOn(mainThreadScheduler) .subscribe(::selectNewCategories), diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java index 8503b1d05..1436ab714 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java @@ -65,14 +65,14 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate @Nullable @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { binding = UploadCategoriesFragmentBinding.inflate(inflater, container, false); return binding.getRoot(); } @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); final Bundle bundle = getArguments(); if (bundle != null) { @@ -104,8 +104,12 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate setTvSubTitle(); binding.tooltip.setOnClickListener(new OnClickListener() { @Override - public void onClick(View v) { - DialogUtil.showAlertDialog(getActivity(), getString(R.string.categories_activity_title), getString(R.string.categories_tooltip), getString(android.R.string.ok), null, true); + public void onClick(final View v) { + DialogUtil.showAlertDialog(requireActivity(), + getString(R.string.categories_activity_title), + getString(R.string.categories_tooltip), + getString(android.R.string.ok), + null); } }); if (media == null) { @@ -146,7 +150,7 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate } } - private void searchForCategory(String query) { + private void searchForCategory(final String query) { presenter.searchForCategories(query); } @@ -170,28 +174,28 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate } @Override - public void showProgress(boolean shouldShow) { + public void showProgress(final boolean shouldShow) { if (binding != null) { binding.pbCategories.setVisibility(shouldShow ? View.VISIBLE : View.GONE); } } @Override - public void showError(String error) { + public void showError(final String error) { if (binding != null) { binding.tilContainerSearch.setError(error); } } @Override - public void showError(int stringResourceId) { + public void showError(final int stringResourceId) { if (binding != null) { binding.tilContainerSearch.setError(getString(stringResourceId)); } } @Override - public void setCategories(List categories) { + public void setCategories(final List categories) { if (categories == null) { adapter.clear(); } else { @@ -229,12 +233,12 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate @Override public void showNoCategorySelected() { if (media == null) { - DialogUtil.showAlertDialog(getActivity(), + DialogUtil.showAlertDialog(requireActivity(), getString(R.string.no_categories_selected), getString(R.string.no_categories_selected_warning_desc), getString(R.string.continue_message), getString(R.string.cancel), - () -> goToNextScreen(), + this::goToNextScreen, null); } else { Toast.makeText(requireContext(), getString(R.string.no_categories_selected), @@ -256,6 +260,7 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate /** * Returns required context */ + @NonNull @Override public Context getFragmentContext() { return requireContext(); @@ -306,7 +311,7 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate public void navigateToLoginScreen() { final String username = sessionManager.getUserName(); final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener( - getActivity(), + requireActivity(), requireActivity().getString(R.string.invalid_login_message), username ); @@ -372,7 +377,7 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate return false; }); - Objects.requireNonNull(getView()).setFocusableInTouchMode(true); + requireView().setFocusableInTouchMode(true); getView().requestFocus(); getView().setOnKeyListener((v, keyCode, event) -> { if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { @@ -387,7 +392,7 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate }); Objects.requireNonNull( - ((AppCompatActivity) Objects.requireNonNull(getActivity())).getSupportActionBar()) + ((AppCompatActivity) requireActivity()).getSupportActionBar()) .hide(); if (getParentFragment().getParentFragment().getParentFragment() @@ -407,7 +412,7 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate super.onStop(); if (media != null) { Objects.requireNonNull( - ((AppCompatActivity) Objects.requireNonNull(getActivity())).getSupportActionBar()) + ((AppCompatActivity) requireActivity()).getSupportActionBar()) .show(); } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.java b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.kt similarity index 56% rename from app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.java rename to app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.kt index 1ff79ab27..2279db7a1 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.kt @@ -1,129 +1,125 @@ -package fr.free.nrw.commons.upload.depicts; +package fr.free.nrw.commons.upload.depicts -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import fr.free.nrw.commons.BasePresenter; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; -import java.util.List; +import android.content.Context +import androidx.lifecycle.LiveData +import fr.free.nrw.commons.BasePresenter +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem /** * The contract with which DepictsFragment and its presenter would talk to each other */ -public interface DepictsContract { - +interface DepictsContract { interface View { /** * Go to category screen */ - void goToNextScreen(); + fun goToNextScreen() /** * Go to media detail screen */ - void goToPreviousScreen(); + fun goToPreviousScreen() /** * show error in case of no depiction selected */ - void noDepictionSelected(); + fun noDepictionSelected() /** * Show progress/Hide progress depending on the boolean value */ - void showProgress(boolean shouldShow); + fun showProgress(shouldShow: Boolean) /** * decides whether to show error values or not depending on the boolean value */ - void showError(Boolean value); + fun showError(value: Boolean) /** * add depictions to list */ - void setDepictsList(List depictedItemList); + fun setDepictsList(depictedItemList: List) /** * Returns required context */ - Context getFragmentContext(); + fun getFragmentContext(): Context /** * Returns to previous fragment */ - void goBackToPreviousScreen(); + fun goBackToPreviousScreen() /** * Gets existing depictions IDs from media */ - List getExistingDepictions(); + fun getExistingDepictions(): List? /** * Shows the progress dialog */ - void showProgressDialog(); + fun showProgressDialog() /** * Hides the progress dialog */ - void dismissProgressDialog(); + fun dismissProgressDialog() /** * Update the depictions */ - void updateDepicts(); + fun updateDepicts() /** * Navigate the user to Login Activity */ - void navigateToLoginScreen(); + fun navigateToLoginScreen() } - interface UserActionListener extends BasePresenter { - + interface UserActionListener : BasePresenter { /** * Takes to previous screen */ - void onPreviousButtonClicked(); + fun onPreviousButtonClicked() /** * Listener for the depicted items selected from the list */ - void onDepictItemClicked(DepictedItem depictedItem); + fun onDepictItemClicked(depictedItem: DepictedItem) /** * asks the repository to fetch depictions for the query - * @param query + * @param query */ - void searchForDepictions(String query); + fun searchForDepictions(query: String) /** * Selects all associated places (if any) as depictions */ - void selectPlaceDepictions(); + fun selectPlaceDepictions() /** * Check if depictions were selected * from the depiction list */ - void verifyDepictions(); + fun verifyDepictions() /** * Clears previous selections */ - void clearPreviousSelection(); + fun clearPreviousSelection() - LiveData> getDepictedItems(); + fun getDepictedItems(): LiveData> /** * Update the depictions */ - void updateDepictions(Media media); + fun updateDepictions(media: Media) /** * Attaches view and media */ - void onAttachViewWithMedia(@NonNull View view, Media media); + fun onAttachViewWithMedia(view: View, media: Media) } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsDao.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsDao.kt index 684400301..139b67d59 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsDao.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsDao.kt @@ -36,7 +36,7 @@ abstract class DepictsDao { /** * Gets all Depicts objects from the database, ordered by lastUsed in descending order. * - * @return A list of Depicts objects. + * @return Deferred list of Depicts objects. */ fun depictsList(): Deferred> = CoroutineScope(Dispatchers.IO).async { @@ -48,7 +48,7 @@ abstract class DepictsDao { * * @param depictedItem The Depicts object to insert. */ - private fun insertDepict(depictedItem: Depicts) = + fun insertDepict(depictedItem: Depicts) = CoroutineScope(Dispatchers.IO).launch { insert(depictedItem) } @@ -59,7 +59,7 @@ abstract class DepictsDao { * @param n The number of depicts to delete. * @return A list of Depicts objects to delete. */ - private suspend fun depictsForDeletion(n: Int): Deferred> = + fun depictsForDeletion(n: Int): Deferred> = CoroutineScope(Dispatchers.IO).async { getDepictsForDeletion(n) } @@ -69,7 +69,7 @@ abstract class DepictsDao { * * @param depicts The Depicts object to delete. */ - private suspend fun deleteDepicts(depicts: Depicts) = + fun deleteDepicts(depicts: Depicts) = CoroutineScope(Dispatchers.IO).launch { delete(depicts) } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java index bd52a8d35..b5a22a622 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java @@ -114,7 +114,7 @@ public class DepictsFragment extends UploadBaseFragment implements DepictsContra setDepictsSubTitle(); binding.tooltip.setOnClickListener(v -> DialogUtil .showAlertDialog(getActivity(), getString(R.string.depicts_step_title), - getString(R.string.depicts_tooltip), getString(android.R.string.ok), null, true)); + getString(R.string.depicts_tooltip), getString(android.R.string.ok), null)); if (media == null) { presenter.onAttachView(this); } else { @@ -218,7 +218,7 @@ public class DepictsFragment extends UploadBaseFragment implements DepictsContra } @Override - public void showError(Boolean value) { + public void showError(boolean value) { if (binding == null) { return; } @@ -398,7 +398,7 @@ public class DepictsFragment extends UploadBaseFragment implements DepictsContra return false; }); - Objects.requireNonNull(getView()).setFocusableInTouchMode(true); + requireView().setFocusableInTouchMode(true); getView().requestFocus(); getView().setOnKeyListener((v, keyCode, event) -> { if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { @@ -411,7 +411,7 @@ public class DepictsFragment extends UploadBaseFragment implements DepictsContra }); Objects.requireNonNull( - ((AppCompatActivity) Objects.requireNonNull(getActivity())).getSupportActionBar()) + ((AppCompatActivity) requireActivity()).getSupportActionBar()) .hide(); if (getParentFragment().getParentFragment().getParentFragment() @@ -431,7 +431,7 @@ public class DepictsFragment extends UploadBaseFragment implements DepictsContra super.onStop(); if (media != null) { Objects.requireNonNull( - ((AppCompatActivity) Objects.requireNonNull(getActivity())).getSupportActionBar()) + ((AppCompatActivity) requireActivity()).getSupportActionBar()) .show(); } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt index 3beedd9d5..02890e608 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt @@ -6,7 +6,8 @@ import androidx.lifecycle.MutableLiveData import fr.free.nrw.commons.Media import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException import fr.free.nrw.commons.bookmarks.items.BookmarkItemsController -import fr.free.nrw.commons.di.CommonsApplicationModule +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.repository.UploadRepository import fr.free.nrw.commons.upload.structure.depictions.DepictedItem import fr.free.nrw.commons.wikidata.WikidataDisambiguationItems @@ -31,8 +32,8 @@ class DepictsPresenter @Inject constructor( private val repository: UploadRepository, - @param:Named(CommonsApplicationModule.IO_THREAD) private val ioScheduler: Scheduler, - @param:Named(CommonsApplicationModule.MAIN_THREAD) private val mainThreadScheduler: Scheduler, + @param:Named(IO_THREAD) private val ioScheduler: Scheduler, + @param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler, ) : DepictsContract.UserActionListener { companion object { private val DUMMY = proxy() @@ -93,14 +94,14 @@ class DepictsPresenter return repository .searchAllEntities(querystring) .subscribeOn(ioScheduler) - .map { repository.selectedDepictions + it + recentDepictedItemList + controller.loadFavoritesItems() } + .map { repository.getSelectedDepictions() + it + recentDepictedItemList + controller.loadFavoritesItems() } .map { it.filterNot { item -> WikidataDisambiguationItems.isDisambiguationItem(item.instanceOfs) } } .map { it.distinctBy(DepictedItem::id) } } else { return Flowable .zip( repository - .getDepictions(repository.selectedExistingDepictions) + .getDepictions(repository.getSelectedExistingDepictions()) .map { list -> list.map { DepictedItem( @@ -118,7 +119,7 @@ class DepictsPresenter ) { it1, it2 -> it1 + it2 }.subscribeOn(ioScheduler) - .map { repository.selectedDepictions + it + recentDepictedItemList + controller.loadFavoritesItems() } + .map { repository.getSelectedDepictions() + it + recentDepictedItemList + controller.loadFavoritesItems() } .map { it.filterNot { item -> WikidataDisambiguationItems.isDisambiguationItem(item.instanceOfs) } } .map { it.distinctBy(DepictedItem::id) } } @@ -135,7 +136,7 @@ class DepictsPresenter */ override fun selectPlaceDepictions() { compositeDisposable.add( - repository.placeDepictions + repository.getPlaceDepictions() .subscribeOn(ioScheduler) .observeOn(mainThreadScheduler) .subscribe(::selectNewDepictions), @@ -188,10 +189,10 @@ class DepictsPresenter * from the depiction list */ override fun verifyDepictions() { - if (repository.selectedDepictions.isNotEmpty()) { + if (repository.getSelectedDepictions().isNotEmpty()) { if (::depictsDao.isInitialized) { // save all the selected Depicted item in room Database - depictsDao.savingDepictsInRoomDataBase(repository.selectedDepictions) + depictsDao.savingDepictsInRoomDataBase(repository.getSelectedDepictions()) } view.goToNextScreen() } else { @@ -205,25 +206,25 @@ class DepictsPresenter */ @SuppressLint("CheckResult") override fun updateDepictions(media: Media) { - if (repository.selectedDepictions.isNotEmpty() || - repository.selectedExistingDepictions.size != view.existingDepictions.size + if (repository.getSelectedDepictions().isNotEmpty() || + repository.getSelectedExistingDepictions().size != view.getExistingDepictions()?.size ) { view.showProgressDialog() val selectedDepictions: MutableList = ( - repository.selectedDepictions.map { it.id }.toMutableList() + - repository.selectedExistingDepictions + repository.getSelectedDepictions().map { it.id }.toMutableList() + + repository.getSelectedExistingDepictions() ).toMutableList() if (selectedDepictions.isNotEmpty()) { if (::depictsDao.isInitialized) { // save all the selected Depicted item in room Database - depictsDao.savingDepictsInRoomDataBase(repository.selectedDepictions) + depictsDao.savingDepictsInRoomDataBase(repository.getSelectedDepictions()) } compositeDisposable.add( depictsHelper - .makeDepictionEdit(view.fragmentContext, media, selectedDepictions) + .makeDepictionEdit(view.getFragmentContext(), media, selectedDepictions) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ @@ -254,7 +255,7 @@ class DepictsPresenter ) { this.view = view this.media = media - repository.selectedExistingDepictions = view.existingDepictions + repository.setSelectedExistingDepictions(view.getExistingDepictions() ?: emptyList()) compositeDisposable.add( searchTerm .observeOn(mainThreadScheduler) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseContract.java b/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseContract.java deleted file mode 100644 index bdc09ca13..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseContract.java +++ /dev/null @@ -1,28 +0,0 @@ -package fr.free.nrw.commons.upload.license; - -import java.util.List; - -import fr.free.nrw.commons.BasePresenter; - -/** - * The contract with with MediaLicenseFragment and its presenter would talk to each other - */ -public interface MediaLicenseContract { - - interface View { - void setLicenses(List licenses); - - void setSelectedLicense(String license); - - void updateLicenseSummary(String selectedLicense, int numberOfItems); - } - - interface UserActionListener extends BasePresenter { - void getLicenses(); - - void selectLicense(String licenseName); - - boolean isWLMSupportedForThisPlace(); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseContract.kt b/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseContract.kt new file mode 100644 index 000000000..27ec1521e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseContract.kt @@ -0,0 +1,24 @@ +package fr.free.nrw.commons.upload.license + +import fr.free.nrw.commons.BasePresenter + +/** + * The contract with with MediaLicenseFragment and its presenter would talk to each other + */ +interface MediaLicenseContract { + interface View { + fun setLicenses(licenses: List?) + + fun setSelectedLicense(license: String?) + + fun updateLicenseSummary(selectedLicense: String?, numberOfItems: Int?) + } + + interface UserActionListener : BasePresenter { + fun getLicenses() + + fun selectLicense(licenseName: String) + + fun isWLMSupportedForThisPlace(): Boolean + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseFragment.java index 01bee67f2..5fb82f2f6 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseFragment.java @@ -69,7 +69,7 @@ public class MediaLicenseFragment extends UploadBaseFragment implements MediaLic getString(R.string.license_step_title), getString(R.string.license_tooltip), getString(android.R.string.ok), - null, true) + null) ); initPresenter(); @@ -141,7 +141,7 @@ public class MediaLicenseFragment extends UploadBaseFragment implements MediaLic } @Override - public void updateLicenseSummary(String licenseSummary, int numberOfItems) { + public void updateLicenseSummary(String licenseSummary, Integer numberOfItems) { String licenseHyperLink = "" + getString(Utils.licenseNameFor(licenseSummary)) + "
"; diff --git a/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicensePresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicensePresenter.java index 79fdf812a..18955636e 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicensePresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicensePresenter.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.upload.license; +import androidx.annotation.NonNull; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.repository.UploadRepository; @@ -27,14 +28,14 @@ public class MediaLicensePresenter implements MediaLicenseContract.UserActionLis private MediaLicenseContract.View view = DUMMY; @Inject - public MediaLicensePresenter(UploadRepository uploadRepository, - @Named("default_preferences") JsonKvStore defaultKVStore) { + public MediaLicensePresenter(final UploadRepository uploadRepository, + @Named("default_preferences") final JsonKvStore defaultKVStore) { this.repository = uploadRepository; this.defaultKVStore = defaultKVStore; } @Override - public void onAttachView(View view) { + public void onAttachView(@NonNull final View view) { this.view = view; } @@ -48,15 +49,15 @@ public class MediaLicensePresenter implements MediaLicenseContract.UserActionLis */ @Override public void getLicenses() { - List licenses = repository.getLicenses(); + final List licenses = repository.getLicenses(); view.setLicenses(licenses); String selectedLicense = defaultKVStore.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_4);//CC_BY_SA_4 is the default one used by the commons web app try {//I have to make sure that the stored default license was not one of the deprecated one's Utils.licenseNameFor(selectedLicense); - } catch (IllegalStateException exception) { - Timber.e(exception.getMessage()); + } catch (final IllegalStateException exception) { + Timber.e(exception); selectedLicense = Prefs.Licenses.CC_BY_SA_4; defaultKVStore.putString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_4); } @@ -70,7 +71,7 @@ public class MediaLicensePresenter implements MediaLicenseContract.UserActionLis * @param licenseName */ @Override - public void selectLicense(String licenseName) { + public void selectLicense(final String licenseName) { repository.setSelectedLicense(licenseName); view.updateLicenseSummary(repository.getSelectedLicense(), repository.getCount()); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java index 105df1837..27556da4a 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java @@ -1,7 +1,6 @@ package fr.free.nrw.commons.upload.mediaDetails; import static android.app.Activity.RESULT_OK; -import static fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags; import android.annotation.SuppressLint; import android.app.Activity; @@ -18,12 +17,15 @@ import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.ImageView; import android.widget.Toast; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.exifinterface.media.ExifInterface; import androidx.recyclerview.widget.LinearLayoutManager; import fr.free.nrw.commons.CameraPosition; -import fr.free.nrw.commons.LocationPicker.LocationPicker; +import fr.free.nrw.commons.locationpicker.LocationPicker; import fr.free.nrw.commons.R; import fr.free.nrw.commons.contributions.MainActivity; import fr.free.nrw.commons.databinding.FragmentUploadMediaDetailFragmentBinding; @@ -42,6 +44,7 @@ import fr.free.nrw.commons.upload.UploadBaseFragment; import fr.free.nrw.commons.upload.UploadItem; import fr.free.nrw.commons.upload.UploadMediaDetail; import fr.free.nrw.commons.upload.UploadMediaDetailAdapter; +import fr.free.nrw.commons.utils.ActivityUtils; import fr.free.nrw.commons.utils.DialogUtil; import fr.free.nrw.commons.utils.ImageUtils; import fr.free.nrw.commons.utils.NetworkUtils; @@ -58,9 +61,24 @@ import timber.log.Timber; public class UploadMediaDetailFragment extends UploadBaseFragment implements UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener { - private static final int REQUEST_CODE = 1211; - private static final int REQUEST_CODE_FOR_EDIT_ACTIVITY = 1212; - private static final int REQUEST_CODE_FOR_VOICE_INPUT = 1213; + private UploadMediaDetailAdapter uploadMediaDetailAdapter; + + private final ActivityResultLauncher startForResult = registerForActivityResult( + new StartActivityForResult(), result -> { + onCameraPosition(result); + }); + + private final ActivityResultLauncher startForEditActivityResult = registerForActivityResult( + new StartActivityForResult(), result -> { + onEditActivityResult(result); + } + ); + + private final ActivityResultLauncher voiceInputResultLauncher = registerForActivityResult( + new StartActivityForResult(), result -> { + onVoiceInput(result); + } + ); public static Activity activity ; @@ -84,8 +102,6 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements private boolean hasUserRemovedLocation; - private UploadMediaDetailAdapter uploadMediaDetailAdapter; - @Inject UploadMediaDetailsContract.UserActionListener presenter; @@ -192,7 +208,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements try { if(!presenter.getImageQuality(indexOfFragment, inAppPictureLocation, getActivity())) { - startActivityWithFlags( + ActivityUtils.startActivityWithFlags( getActivity(), MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP, Intent.FLAG_ACTIVITY_SINGLE_TOP); } @@ -279,7 +295,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements */ private void initRecyclerView() { uploadMediaDetailAdapter = new UploadMediaDetailAdapter(this, - defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, ""), recentLanguagesDao); + defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, ""), recentLanguagesDao, voiceInputResultLauncher); uploadMediaDetailAdapter.setCallback(this::showInfoAlert); uploadMediaDetailAdapter.setEventListener(this); binding.rvDescriptions.setLayoutManager(new LinearLayoutManager(getContext())); @@ -293,7 +309,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements */ private void showInfoAlert(int titleStringID, int messageStringId) { DialogUtil.showAlertDialog(getActivity(), getString(titleStringID), - getString(messageStringId), getString(android.R.string.ok), null, true); + getString(messageStringId), getString(android.R.string.ok), null); } @@ -320,6 +336,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements BasicKvStore basicKvStore = new BasicKvStore(getActivity(), "IsAnyImageCancelled"); if (!basicKvStore.getBoolean("IsAnyImageCancelled", false)) { SimilarImageDialogFragment newFragment = new SimilarImageDialogFragment(); + newFragment.setCancelable(false); newFragment.setCallback(new SimilarImageDialogFragment.Callback() { @Override public void onPositiveResponse() { @@ -434,7 +451,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements // Execute when user cancels the upload of the specified place UploadActivity.nearbyPopupAnswers.put(place, false); }, - customLayout, true); + customLayout + ); } } @@ -510,8 +528,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP); onImageValidationSuccess(); }, null, - checkBoxView, - false); + checkBoxView); } else { uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP); onImageValidationSuccess(); @@ -572,8 +589,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements basicKvStore.putBoolean(keyForShowingAlertDialog, false); activity.finish(); }, - null, - false + null ); } } catch (Exception e) { @@ -593,14 +609,14 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements * This method is called to start the image editing activity for a specific UploadItem. * It sets the UploadItem as the currently editable item, creates an intent to launch the * EditActivity, and passes the image file path as an extra in the intent. The activity - * is started with a request code, allowing the result to be handled in onActivityResult. + * is started using resultLauncher that handles the result in respective callback. */ @Override public void showEditActivity(UploadItem uploadItem) { editableUploadItem = uploadItem; Intent intent = new Intent(getContext(), EditActivity.class); intent.putExtra("image", uploadableFile.getFilePath().toString()); - startActivityForResult(intent, REQUEST_CODE_FOR_EDIT_ACTIVITY); + startForEditActivityResult.launch(intent); } /** @@ -615,6 +631,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements double defaultLongitude = -122.431297; double defaultZoom = 16.0; + final Intent locationPickerIntent; + /* Retrieve image location from EXIF if present or check if user has provided location while using the in-app camera. Use location of last UploadItem if none of them is available */ @@ -624,10 +642,11 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements .getDecLatitude(); defaultLongitude = uploadItem.getGpsCoords().getDecLongitude(); defaultZoom = uploadItem.getGpsCoords().getZoomLevel(); - startActivityForResult(new LocationPicker.IntentBuilder() + + locationPickerIntent = new LocationPicker.IntentBuilder() .defaultLocation(new CameraPosition(defaultLatitude,defaultLongitude,defaultZoom)) .activityKey("UploadActivity") - .build(getActivity()), REQUEST_CODE); + .build(getActivity()); } else { if (defaultKvStore.getString(LAST_LOCATION) != null) { final String[] locationLatLng @@ -638,27 +657,20 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements if (defaultKvStore.getString(LAST_ZOOM) != null) { defaultZoom = Double.parseDouble(defaultKvStore.getString(LAST_ZOOM)); } - startActivityForResult(new LocationPicker.IntentBuilder() + + locationPickerIntent = new LocationPicker.IntentBuilder() .defaultLocation(new CameraPosition(defaultLatitude,defaultLongitude,defaultZoom)) .activityKey("NoLocationUploadActivity") - .build(getActivity()), REQUEST_CODE); + .build(getActivity()); } + startForResult.launch(locationPickerIntent); } - /** - * Get the coordinates and update the existing coordinates. - * @param requestCode code of request - * @param resultCode code of result - * @param data intent - */ - @Override - public void onActivityResult(final int requestCode, final int resultCode, - @Nullable final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == REQUEST_CODE && resultCode == RESULT_OK) { + private void onCameraPosition(ActivityResult result){ + if (result.getResultCode() == RESULT_OK) { - assert data != null; - final CameraPosition cameraPosition = LocationPicker.getCameraPosition(data); + assert result.getData() != null; + final CameraPosition cameraPosition = LocationPicker.getCameraPosition(result.getData()); if (cameraPosition != null) { @@ -678,8 +690,21 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements removeLocation(); } } - if (requestCode == REQUEST_CODE_FOR_EDIT_ACTIVITY && resultCode == RESULT_OK) { - String result = data.getStringExtra("editedImageFilePath"); + } + + private void onVoiceInput(ActivityResult result) { + if (result.getResultCode() == RESULT_OK && result.getData() != null) { + ArrayList resultData = result.getData().getStringArrayListExtra( + RecognizerIntent.EXTRA_RESULTS); + uploadMediaDetailAdapter.handleSpeechResult(resultData.get(0)); + }else { + Timber.e("Error %s", result.getResultCode()); + } + } + + private void onEditActivityResult(ActivityResult result){ + if (result.getResultCode() == RESULT_OK) { + String path = result.getData().getStringExtra("editedImageFilePath"); if (Objects.equals(result, "Error")) { Timber.e("Error in rotating image"); @@ -687,24 +712,15 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements } try { if (binding != null){ - binding.backgroundImage.setImageURI(Uri.fromFile(new File(result))); + binding.backgroundImage.setImageURI(Uri.fromFile(new File(path))); } - editableUploadItem.setContentUri(Uri.fromFile(new File(result))); + editableUploadItem.setContentUri(Uri.fromFile(new File(path))); callback.changeThumbnail(indexOfFragment, - result); + path); } catch (Exception e) { Timber.e(e); } } - else if (requestCode == REQUEST_CODE_FOR_VOICE_INPUT) { - if (resultCode == RESULT_OK && data != null) { - ArrayList result = data.getStringArrayListExtra( - RecognizerIntent.EXTRA_RESULTS); - uploadMediaDetailAdapter.handleSpeechResult(result.get(0)); - }else { - Timber.e("Error %s", resultCode); - } - } } /** @@ -809,7 +825,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements @Override public void displayAddLocationDialog(final Runnable onSkipClicked) { isMissingLocationDialog = true; - DialogUtil.showAlertDialog(Objects.requireNonNull(getActivity()), + DialogUtil.showAlertDialog(requireActivity(), getString(R.string.no_location_found_title), getString(R.string.no_location_found_message), getString(R.string.add_location), diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java deleted file mode 100644 index 9b789e046..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java +++ /dev/null @@ -1,115 +0,0 @@ -package fr.free.nrw.commons.upload.mediaDetails; - -import android.app.Activity; -import fr.free.nrw.commons.BasePresenter; -import fr.free.nrw.commons.filepicker.UploadableFile; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.upload.ImageCoordinates; -import fr.free.nrw.commons.upload.SimilarImageInterface; -import fr.free.nrw.commons.upload.UploadMediaDetail; -import fr.free.nrw.commons.upload.UploadItem; -import java.util.List; - -/** - * The contract with with UploadMediaDetails and its presenter would talk to each other - */ -public interface UploadMediaDetailsContract { - - interface View extends SimilarImageInterface { - - void onImageProcessed(UploadItem uploadItem, Place place); - - void onNearbyPlaceFound(UploadItem uploadItem, Place place); - - void showProgress(boolean shouldShow); - - void onImageValidationSuccess(); - - void showMessage(int stringResourceId, int colorResourceId); - - void showMessage(String message, int colorResourceId); - - void showDuplicatePicturePopup(UploadItem uploadItem); - - /** - * Shows a dialog alerting the user that internet connection is required for upload process - * Recalls UploadMediaPresenter.getImageQuality for all the next upload items, - * if there is network connectivity and then the user presses okay - */ - void showConnectionErrorPopup(); - - /** - * Shows a dialog alerting the user that internet connection is required for upload process - * Does nothing if there is network connectivity and then the user presses okay - */ - void showConnectionErrorPopupForCaptionCheck(); - - void showExternalMap(UploadItem uploadItem); - - void showEditActivity(UploadItem uploadItem); - - void updateMediaDetails(List uploadMediaDetails); - - void displayAddLocationDialog(Runnable runnable); - } - - interface UserActionListener extends BasePresenter { - - void receiveImage(UploadableFile uploadableFile, Place place, LatLng inAppPictureLocation); - - void setUploadMediaDetails(List uploadMediaDetails, int uploadItemIndex); - - /** - * Calculates the image quality - * - * @param uploadItemIndex Index of the UploadItem whose quality is to be checked - * @param inAppPictureLocation In app picture location (if any) - * @param activity Context reference - * @return true if no internal error occurs, else returns false - */ - boolean getImageQuality(int uploadItemIndex, LatLng inAppPictureLocation, Activity activity); - - /** - * Checks if the image has a location. Displays a dialog alerting user that no location has - * been to added to the image and asking them to add one, if location was not removed by the - * user - * - * @param uploadItemIndex Index of the uploadItem which has no location - * @param inAppPictureLocation In app picture location (if any) - * @param hasUserRemovedLocation True if user has removed location from the image - */ - void displayLocDialog(int uploadItemIndex, LatLng inAppPictureLocation, - boolean hasUserRemovedLocation); - - /** - * Used to check image quality from stored qualities and display dialogs - * - * @param uploadItem UploadItem whose quality is to be checked - * @param index Index of the UploadItem whose quality is to be checked - */ - void checkImageQuality(UploadItem uploadItem, int index); - - /** - * Updates the image qualities stored in JSON, whenever an image is deleted - * - * @param size Size of uploadableFiles - * @param index Index of the UploadItem which was deleted - */ - void updateImageQualitiesJSON(int size, int index); - - - void copyTitleAndDescriptionToSubsequentMedia(int indexInViewFlipper); - - void fetchTitleAndDescription(int indexInViewFlipper); - - void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex); - - void onMapIconClicked(int indexInViewFlipper); - - void onEditButtonClicked(int indexInViewFlipper); - - void onUserConfirmedUploadIsOfPlace(Place place); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.kt b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.kt new file mode 100644 index 000000000..ca4d8e67b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.kt @@ -0,0 +1,122 @@ +package fr.free.nrw.commons.upload.mediaDetails + +import android.app.Activity +import fr.free.nrw.commons.BasePresenter +import fr.free.nrw.commons.filepicker.UploadableFile +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.upload.ImageCoordinates +import fr.free.nrw.commons.upload.SimilarImageInterface +import fr.free.nrw.commons.upload.UploadItem +import fr.free.nrw.commons.upload.UploadMediaDetail + +/** + * The contract with with UploadMediaDetails and its presenter would talk to each other + */ +interface UploadMediaDetailsContract { + interface View : SimilarImageInterface { + fun onImageProcessed(uploadItem: UploadItem?, place: Place?) + + fun onNearbyPlaceFound(uploadItem: UploadItem?, place: Place?) + + fun showProgress(shouldShow: Boolean) + + fun onImageValidationSuccess() + + fun showMessage(stringResourceId: Int, colorResourceId: Int) + + fun showMessage(message: String?, colorResourceId: Int) + + fun showDuplicatePicturePopup(uploadItem: UploadItem?) + + /** + * Shows a dialog alerting the user that internet connection is required for upload process + * Recalls UploadMediaPresenter.getImageQuality for all the next upload items, + * if there is network connectivity and then the user presses okay + */ + fun showConnectionErrorPopup() + + /** + * Shows a dialog alerting the user that internet connection is required for upload process + * Does nothing if there is network connectivity and then the user presses okay + */ + fun showConnectionErrorPopupForCaptionCheck() + + fun showExternalMap(uploadItem: UploadItem?) + + fun showEditActivity(uploadItem: UploadItem?) + + fun updateMediaDetails(uploadMediaDetails: List?) + + fun displayAddLocationDialog(runnable: Runnable?) + } + + interface UserActionListener : BasePresenter { + fun receiveImage( + uploadableFile: UploadableFile?, + place: Place?, + inAppPictureLocation: LatLng? + ) + + fun setUploadMediaDetails( + uploadMediaDetails: List?, + uploadItemIndex: Int + ) + + /** + * Calculates the image quality + * + * @param uploadItemIndex Index of the UploadItem whose quality is to be checked + * @param inAppPictureLocation In app picture location (if any) + * @param activity Context reference + * @return true if no internal error occurs, else returns false + */ + fun getImageQuality( + uploadItemIndex: Int, + inAppPictureLocation: LatLng?, + activity: Activity? + ): Boolean + + /** + * Checks if the image has a location. Displays a dialog alerting user that no location has + * been to added to the image and asking them to add one, if location was not removed by the + * user + * + * @param uploadItemIndex Index of the uploadItem which has no location + * @param inAppPictureLocation In app picture location (if any) + * @param hasUserRemovedLocation True if user has removed location from the image + */ + fun displayLocDialog( + uploadItemIndex: Int, inAppPictureLocation: LatLng?, + hasUserRemovedLocation: Boolean + ) + + /** + * Used to check image quality from stored qualities and display dialogs + * + * @param uploadItem UploadItem whose quality is to be checked + * @param index Index of the UploadItem whose quality is to be checked + */ + fun checkImageQuality(uploadItem: UploadItem?, index: Int) + + /** + * Updates the image qualities stored in JSON, whenever an image is deleted + * + * @param size Size of uploadableFiles + * @param index Index of the UploadItem which was deleted + */ + fun updateImageQualitiesJSON(size: Int, index: Int) + + fun copyTitleAndDescriptionToSubsequentMedia(indexInViewFlipper: Int) + + fun fetchTitleAndDescription(indexInViewFlipper: Int) + + fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates?, uploadItemIndex: Int) + + fun onMapIconClicked(indexInViewFlipper: Int) + + fun onEditButtonClicked(indexInViewFlipper: Int) + + fun onUserConfirmedUploadIsOfPlace(place: Place?) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java index 7152d4d8f..6c234cdaf 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java @@ -79,10 +79,10 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt public static boolean isCategoriesDialogShowing; @Inject - public UploadMediaPresenter(UploadRepository uploadRepository, - @Named("default_preferences") JsonKvStore defaultKVStore, - @Named(IO_THREAD) Scheduler ioScheduler, - @Named(MAIN_THREAD) Scheduler mainThreadScheduler) { + public UploadMediaPresenter(final UploadRepository uploadRepository, + @Named("default_preferences") final JsonKvStore defaultKVStore, + @Named(IO_THREAD) final Scheduler ioScheduler, + @Named(MAIN_THREAD) final Scheduler mainThreadScheduler) { this.repository = uploadRepository; this.defaultKVStore = defaultKVStore; this.ioScheduler = ioScheduler; @@ -91,7 +91,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt } @Override - public void onAttachView(View view) { + public void onAttachView(final View view) { this.view = view; } @@ -103,23 +103,18 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt /** * Sets the Upload Media Details for the corresponding upload item - * - * @param uploadMediaDetails - * @param uploadItemIndex */ @Override - public void setUploadMediaDetails(List uploadMediaDetails, int uploadItemIndex) { + public void setUploadMediaDetails(final List uploadMediaDetails, final int uploadItemIndex) { repository.getUploads().get(uploadItemIndex).setMediaDetails(uploadMediaDetails); } /** * Receives the corresponding uploadable file, processes it and return the view with and uplaod item - * @param uploadableFile - * @param place */ @Override public void receiveImage(final UploadableFile uploadableFile, final Place place, - LatLng inAppPictureLocation) { + final LatLng inAppPictureLocation) { view.showProgress(true); compositeDisposable.add( repository @@ -129,9 +124,9 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt if (place.location != null) { final String countryCode = reverseGeoCode(place.location); if (countryCode != null && WLM_SUPPORTED_COUNTRIES - .contains(countryCode.toLowerCase())) { + .contains(countryCode.toLowerCase(Locale.ROOT))) { uploadItem.setWLMUpload(true); - uploadItem.setCountryCode(countryCode.toLowerCase()); + uploadItem.setCountryCode(countryCode.toLowerCase(Locale.ROOT)); } } } @@ -186,7 +181,6 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt /** * This method checks for the nearest location that needs images and suggests it to the user. - * @param uploadItem */ private void checkNearbyPlaces(final UploadItem uploadItem) { final Disposable checkNearbyPlaces = Maybe.fromCallable(() -> repository @@ -213,10 +207,10 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @param hasUserRemovedLocation True if user has removed location from the image */ @Override - public void displayLocDialog(int uploadItemIndex, LatLng inAppPictureLocation, - boolean hasUserRemovedLocation) { + public void displayLocDialog(final int uploadItemIndex, final LatLng inAppPictureLocation, + final boolean hasUserRemovedLocation) { final List uploadItems = repository.getUploads(); - UploadItem uploadItem = uploadItems.get(uploadItemIndex); + final UploadItem uploadItem = uploadItems.get(uploadItemIndex); if (uploadItem.getGpsCoords().getDecimalCoords() == null && inAppPictureLocation == null && !hasUserRemovedLocation) { final Runnable onSkipClicked = () -> { @@ -233,7 +227,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * * @param uploadItem UploadItem whose caption is checked */ - private void verifyCaptionQuality(UploadItem uploadItem) { + private void verifyCaptionQuality(final UploadItem uploadItem) { view.showProgress(true); compositeDisposable.add( repository @@ -262,7 +256,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @param errorCode Error code of the UploadItem * @param uploadItem UploadItem whose caption is checked */ - public void handleCaptionResult(Integer errorCode, UploadItem uploadItem) { + public void handleCaptionResult(final Integer errorCode, final UploadItem uploadItem) { // If errorCode is empty caption show message if (errorCode == EMPTY_CAPTION) { Timber.d("Captions are empty. Showing toast"); @@ -285,11 +279,9 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt /** * Copies the caption and description of the current item to the subsequent media - * - * @param indexInViewFlipper */ @Override - public void copyTitleAndDescriptionToSubsequentMedia(int indexInViewFlipper) { + public void copyTitleAndDescriptionToSubsequentMedia(final int indexInViewFlipper) { for(int i = indexInViewFlipper+1; i < repository.getCount(); i++){ final UploadItem subsequentUploadItem = repository.getUploads().get(i); subsequentUploadItem.setMediaDetails(deepCopy(repository.getUploads().get(indexInViewFlipper).getUploadMediaDetails())); @@ -298,36 +290,34 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt /** * Fetches and set the caption and description of the item - * - * @param indexInViewFlipper */ @Override - public void fetchTitleAndDescription(int indexInViewFlipper) { + public void fetchTitleAndDescription(final int indexInViewFlipper) { final UploadItem currentUploadItem = repository.getUploads().get(indexInViewFlipper); view.updateMediaDetails(currentUploadItem.getUploadMediaDetails()); } @NotNull - private List deepCopy(List uploadMediaDetails) { + private List deepCopy(final List uploadMediaDetails) { final ArrayList newList = new ArrayList<>(); - for (UploadMediaDetail uploadMediaDetail : uploadMediaDetails) { + for (final UploadMediaDetail uploadMediaDetail : uploadMediaDetails) { newList.add(uploadMediaDetail.javaCopy()); } return newList; } @Override - public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex) { + public void useSimilarPictureCoordinates(final ImageCoordinates imageCoordinates, final int uploadItemIndex) { repository.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex); } @Override - public void onMapIconClicked(int indexInViewFlipper) { + public void onMapIconClicked(final int indexInViewFlipper) { view.showExternalMap(repository.getUploads().get(indexInViewFlipper)); } @Override - public void onEditButtonClicked(int indexInViewFlipper){ + public void onEditButtonClicked(final int indexInViewFlipper){ view.showEditActivity(repository.getUploads().get(indexInViewFlipper)); } @@ -338,9 +328,9 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @param place The place to be associated with the uploads. */ @Override - public void onUserConfirmedUploadIsOfPlace(Place place) { + public void onUserConfirmedUploadIsOfPlace(final Place place) { final List uploads = repository.getUploads(); - for (UploadItem uploadItem : uploads) { + for (final UploadItem uploadItem : uploads) { uploadItem.setPlace(place); final List uploadMediaDetails = uploadItem.getUploadMediaDetails(); // Update UploadMediaDetail object for this UploadItem @@ -362,11 +352,11 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @return true if no internal error occurs, else returns false */ @Override - public boolean getImageQuality(int uploadItemIndex, LatLng inAppPictureLocation, - Activity activity) { + public boolean getImageQuality(final int uploadItemIndex, final LatLng inAppPictureLocation, + final Activity activity) { final List uploadItems = repository.getUploads(); view.showProgress(true); - if (uploadItems.size() == 0) { + if (uploadItems.isEmpty()) { view.showProgress(false); // No internationalization required for this error message because it's an internal error. view.showMessage( @@ -374,7 +364,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt R.color.color_error); return false; } - UploadItem uploadItem = uploadItems.get(uploadItemIndex); + final UploadItem uploadItem = uploadItems.get(uploadItemIndex); compositeDisposable.add( repository .getImageQuality(uploadItem, inAppPictureLocation) @@ -404,12 +394,12 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @param activity Context reference * @param uploadItem UploadItem whose quality is to be checked */ - private void storeImageQuality(Integer imageResult, int uploadItemIndex, Activity activity, - UploadItem uploadItem) { - BasicKvStore store = new BasicKvStore(activity, + private void storeImageQuality(final Integer imageResult, final int uploadItemIndex, final Activity activity, + final UploadItem uploadItem) { + final BasicKvStore store = new BasicKvStore(activity, UploadActivity.storeNameForCurrentUploadImagesSize); - String value = store.getString(keyForCurrentUploadImageQualities, null); - JSONObject jsonObject; + final String value = store.getString(keyForCurrentUploadImageQualities, null); + final JSONObject jsonObject; try { if (value != null) { jsonObject = new JSONObject(value); @@ -418,7 +408,8 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt } jsonObject.put("UploadItem" + uploadItemIndex, imageResult); store.putString(keyForCurrentUploadImageQualities, jsonObject.toString()); - } catch (Exception e) { + } catch (final Exception e) { + Timber.e(e); } if (uploadItemIndex == 0) { @@ -438,20 +429,20 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @param index Index of the UploadItem whose quality is to be checked */ @Override - public void checkImageQuality(UploadItem uploadItem, int index) { + public void checkImageQuality(final UploadItem uploadItem, final int index) { if ((uploadItem.getImageQuality() != IMAGE_OK) && (uploadItem.getImageQuality() != IMAGE_KEEP)) { - BasicKvStore store = new BasicKvStore(activity, + final BasicKvStore store = new BasicKvStore(activity, UploadActivity.storeNameForCurrentUploadImagesSize); - String value = store.getString(keyForCurrentUploadImageQualities, null); - JSONObject jsonObject; + final String value = store.getString(keyForCurrentUploadImageQualities, null); + final JSONObject jsonObject; try { if (value != null) { jsonObject = new JSONObject(value); } else { jsonObject = new JSONObject(); } - Integer imageQuality = (int) jsonObject.get("UploadItem" + index); + final Integer imageQuality = (int) jsonObject.get("UploadItem" + index); view.showProgress(false); if (imageQuality == IMAGE_OK) { uploadItem.setHasInvalidLocation(false); @@ -459,7 +450,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt } else { handleBadImage(imageQuality, uploadItem, index); } - } catch (Exception e) { + } catch (final Exception e) { } } } @@ -471,11 +462,11 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @param index Index of the UploadItem which was deleted */ @Override - public void updateImageQualitiesJSON(int size, int index) { - BasicKvStore store = new BasicKvStore(activity, + public void updateImageQualitiesJSON(final int size, final int index) { + final BasicKvStore store = new BasicKvStore(activity, UploadActivity.storeNameForCurrentUploadImagesSize); - String value = store.getString(keyForCurrentUploadImageQualities, null); - JSONObject jsonObject; + final String value = store.getString(keyForCurrentUploadImageQualities, null); + final JSONObject jsonObject; try { if (value != null) { jsonObject = new JSONObject(value); @@ -487,7 +478,8 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt } jsonObject.remove("UploadItem" + (size - 1)); store.putString(keyForCurrentUploadImageQualities, jsonObject.toString()); - } catch (Exception e) { + } catch (final Exception e) { + Timber.e(e); } } @@ -498,8 +490,8 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @param uploadItem UploadItem whose quality is bad * @param index Index of item whose quality is bad */ - public void handleBadImage(Integer errorCode, - UploadItem uploadItem, int index) { + public void handleBadImage(final Integer errorCode, + final UploadItem uploadItem, final int index) { Timber.d("Handle bad picture with error code %d", errorCode); if (errorCode >= 8) { // If location of image and nearby does not match uploadItem.setHasInvalidLocation(true); @@ -520,9 +512,9 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt * @param activity Context reference * @param uploadItem UploadItem which has problems */ - public void showBadImagePopup(Integer errorCode, - int index, Activity activity, UploadItem uploadItem) { - String errorMessageForResult = getErrorMessageForResult(activity, errorCode); + public void showBadImagePopup(final Integer errorCode, + final int index, final Activity activity, final UploadItem uploadItem) { + final String errorMessageForResult = getErrorMessageForResult(activity, errorCode); if (!StringUtils.isBlank(errorMessageForResult)) { DialogUtil.showAlertDialog(activity, activity.getString(R.string.upload_problem_image), @@ -537,20 +529,16 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt presenterCallback.deletePictureAtIndex(index); } ).setCancelable(false); - } else { } //If the error message is null, we will probably not show anything } /** * notifies the user that a similar image exists - * @param originalFilePath - * @param possibleFilePath - * @param similarImageCoordinates */ @Override - public void showSimilarImageFragment(String originalFilePath, String possibleFilePath, - ImageCoordinates similarImageCoordinates) { + public void showSimilarImageFragment(final String originalFilePath, final String possibleFilePath, + final ImageCoordinates similarImageCoordinates) { view.showSimilarImageFragment(originalFilePath, possibleFilePath, similarImageCoordinates ); diff --git a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictModel.kt b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictModel.kt index 7242b8eed..9337cb8b5 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictModel.kt @@ -39,7 +39,7 @@ class DepictModel for (place in places) { place.wikiDataEntityId?.let { qids.add(it) } } - repository.uploads.forEach { item -> + repository.getUploads().forEach { item -> if (item.gpsCoords != null && item.gpsCoords.imageCoordsExists) { Coordinates2Country .countryQID( diff --git a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/UploadDepictsCallback.java b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/UploadDepictsCallback.java deleted file mode 100644 index d176f9c18..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/UploadDepictsCallback.java +++ /dev/null @@ -1,8 +0,0 @@ -package fr.free.nrw.commons.upload.structure.depictions; - -/** - * Listener to trigger callback whenever a depicts item is clicked - */ -public interface UploadDepictsCallback { - void depictsClicked(DepictedItem item); -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt index fb2ca7b3a..ae2c461f8 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt @@ -41,12 +41,13 @@ import fr.free.nrw.commons.upload.UploadProgressActivity import fr.free.nrw.commons.upload.UploadResult import fr.free.nrw.commons.wikidata.WikidataEditService import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber import java.util.Date +import java.util.Random import java.util.regex.Pattern import javax.inject.Inject @@ -438,7 +439,7 @@ class UploadWorker( username, ) CommonsApplication - .getInstance() + .instance!! .clearApplicationData(appContext, logoutListener) } } @@ -495,14 +496,14 @@ class UploadWorker( withContext(Dispatchers.Main) { wikidataEditService.handleImageClaimResult( - contribution.wikidataPlace, + contribution.wikidataPlace!!, revisionID, ) } } else { withContext(Dispatchers.Main) { wikidataEditService.handleImageClaimResult( - contribution.wikidataPlace, + contribution.wikidataPlace!!, null, ) } @@ -534,7 +535,7 @@ class UploadWorker( contribution.contentUri?.let { val imageSha1 = contribution.imageSHA1.toString() val modifiedSha1 = fileUtilsWrapper.getSHA1(fileUtilsWrapper.getFileInputStream(contribution.localUri?.path)) - MainScope().launch { + CoroutineScope(Dispatchers.IO).launch { uploadedStatusDao.insertUploaded( UploadedStatus( imageSha1, @@ -548,33 +549,30 @@ class UploadWorker( } private fun findUniqueFileName(fileName: String): String { - var sequenceFileName: String? - var sequenceNumber = 1 - while (true) { + var sequenceFileName: String? = fileName + val random = Random() + + // Loops until sequenceFileName does not match any existing file names + while (mediaClient + .checkPageExistsUsingTitle( + String.format( + "File:%s", + sequenceFileName, + ), + ).blockingGet()) { + + // Generate a random 5-character alphanumeric string + val randomHash = (random.nextInt(90000) + 10000).toString() + sequenceFileName = - if (sequenceNumber == 1) { - fileName + if (fileName.indexOf('.') == -1) { + "$fileName #$randomHash" } else { - if (fileName.indexOf('.') == -1) { - "$fileName $sequenceNumber" - } else { - val regex = - Pattern.compile("^(.*)(\\..+?)$") - val regexMatcher = regex.matcher(fileName) - regexMatcher.replaceAll("$1 $sequenceNumber$2") - } + val regex = + Pattern.compile("^(.*)(\\..+?)$") + val regexMatcher = regex.matcher(fileName) + regexMatcher.replaceAll("$1 #$randomHash") } - if (!mediaClient - .checkPageExistsUsingTitle( - String.format( - "File:%s", - sequenceFileName, - ), - ).blockingGet() - ) { - break - } - sequenceNumber++ } return sequenceFileName!! } diff --git a/app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.java b/app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.java deleted file mode 100644 index d5188027d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.java +++ /dev/null @@ -1,31 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.text.Editable; -import android.text.TextWatcher; - -import androidx.annotation.NonNull; - -public class AbstractTextWatcher implements TextWatcher { - private final TextChange textChange; - - public AbstractTextWatcher(@NonNull TextChange textChange) { - this.textChange = textChange; - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - textChange.onTextChanged(s.toString()); - } - - @Override - public void afterTextChanged(Editable s) { - } - - public interface TextChange { - void onTextChanged(String value); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.kt b/app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.kt new file mode 100644 index 000000000..7e7275049 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.kt @@ -0,0 +1,25 @@ +package fr.free.nrw.commons.utils + +import android.text.Editable +import android.text.TextWatcher + +class AbstractTextWatcher( + private val textChange: TextChange +) : TextWatcher { + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // No-op + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + textChange.onTextChanged(s.toString()) + } + + override fun afterTextChanged(s: Editable?) { + // No-op + } + + fun interface TextChange { + fun onTextChanged(value: String) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ActivityUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/ActivityUtils.java deleted file mode 100644 index 4806585dc..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/ActivityUtils.java +++ /dev/null @@ -1,15 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.content.Context; -import android.content.Intent; - -public class ActivityUtils { - - public static void startActivityWithFlags(Context context, Class cls, int... flags) { - Intent intent = new Intent(context, cls); - for (int flag : flags) { - intent.addFlags(flag); - } - context.startActivity(intent); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ActivityUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/ActivityUtils.kt new file mode 100644 index 000000000..899daaf6b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/ActivityUtils.kt @@ -0,0 +1,16 @@ +package fr.free.nrw.commons.utils + +import android.content.Context +import android.content.Intent + +object ActivityUtils { + + @JvmStatic + fun startActivityWithFlags(context: Context, cls: Class, vararg flags: Int) { + val intent = Intent(context, cls) + for (flag in flags) { + intent.addFlags(flag) + } + context.startActivity(intent) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CommonsDateUtil.java b/app/src/main/java/fr/free/nrw/commons/utils/CommonsDateUtil.java deleted file mode 100644 index 39ddca683..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/CommonsDateUtil.java +++ /dev/null @@ -1,44 +0,0 @@ -package fr.free.nrw.commons.utils; - -import java.text.SimpleDateFormat; -import java.util.Locale; -import java.util.TimeZone; - -/** - * Provides util functions for formatting date time - * Most of our formatting needs are addressed by the data library's DateUtil class - * Methods should be added here only if DateUtil class doesn't provide for it already - */ -public class CommonsDateUtil { - - /** - * Gets SimpleDateFormat for short date pattern - * @return simpledateformat - */ - public static SimpleDateFormat getIso8601DateFormatShort() { - SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.ROOT); - simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - return simpleDateFormat; - } - - /** - * Gets SimpleDateFormat for date pattern returned by Media object - * @return simpledateformat - */ - public static SimpleDateFormat getMediaSimpleDateFormat() { - SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT); - simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - return simpleDateFormat; - } - - /** - * Gets the timestamp pattern for a date - * @return timestamp - */ - public static SimpleDateFormat getIso8601DateFormatTimestamp() { - final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", - Locale.ROOT); - simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - return simpleDateFormat; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CommonsDateUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/CommonsDateUtil.kt new file mode 100644 index 000000000..c076e19ce --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/CommonsDateUtil.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.utils + +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +/** + * Provides util functions for formatting date time. + * Most of our formatting needs are addressed by the data library's DateUtil class. + * Methods should be added here only if DateUtil class doesn't provide for it already. + */ +object CommonsDateUtil { + + /** + * Gets SimpleDateFormat for short date pattern. + * @return simpleDateFormat + */ + @JvmStatic + fun getIso8601DateFormatShort(): SimpleDateFormat { + val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) + simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC") + return simpleDateFormat + } + + /** + * Gets SimpleDateFormat for date pattern returned by Media object. + * @return simpleDateFormat + */ + @JvmStatic + fun getMediaSimpleDateFormat(): SimpleDateFormat { + val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT) + simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC") + return simpleDateFormat + } + + /** + * Gets the timestamp pattern for a date. + * @return timestamp + */ + @JvmStatic + fun getIso8601DateFormatTimestamp(): SimpleDateFormat { + val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT) + simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC") + return simpleDateFormat + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ConfigUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/ConfigUtils.kt index bfd0bbb6b..332c8d023 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/ConfigUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/ConfigUtils.kt @@ -4,6 +4,9 @@ import android.content.Context import android.content.pm.PackageManager import fr.free.nrw.commons.BuildConfig +// TODO - this can be constructed in a Dagger provider method, in a module and injected. No need +// to compute these values every time, and it means we can avoid having a Context in various +// other places in the app. object ConfigUtils { @JvmStatic val isBetaFlavour: Boolean = BuildConfig.FLAVOR == "beta" diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt index fc80252fc..62bd3f1a9 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt @@ -63,7 +63,7 @@ class CustomSelectorUtils { fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) val sha1 = fileUtilsWrapper.getSHA1( - fileUtilsWrapper.getFileInputStream(uploadableFile.filePath), + fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()), ) uploadableFile.file.delete() sha1 diff --git a/app/src/main/java/fr/free/nrw/commons/utils/DateUtil.java b/app/src/main/java/fr/free/nrw/commons/utils/DateUtil.java deleted file mode 100644 index 1d2a8fdf7..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/DateUtil.java +++ /dev/null @@ -1,53 +0,0 @@ -package fr.free.nrw.commons.utils; - -import static android.text.format.DateFormat.getBestDateTimePattern; - -import androidx.annotation.NonNull; - -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.TimeZone; - -public final class DateUtil { - private static Map DATE_FORMATS = new HashMap<>(); - - // TODO: Switch to DateTimeFormatter when minSdk = 26. - - public static synchronized String iso8601DateFormat(Date date) { - return getCachedDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT, true).format(date); - } - - public static synchronized Date iso8601DateParse(String date) throws ParseException { - return getCachedDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT, true).parse(date); - } - - public static String getMonthOnlyDateString(@NonNull Date date) { - return getDateStringWithSkeletonPattern(date, "MMMM d"); - } - - public static String getExtraShortDateString(@NonNull Date date) { - return getDateStringWithSkeletonPattern(date, "MMM d"); - } - - public static synchronized String getDateStringWithSkeletonPattern(@NonNull Date date, @NonNull String pattern) { - return getCachedDateFormat(getBestDateTimePattern(Locale.getDefault(), pattern), Locale.getDefault(), false).format(date); - } - - private static SimpleDateFormat getCachedDateFormat(String pattern, Locale locale, boolean utc) { - if (!DATE_FORMATS.containsKey(pattern)) { - SimpleDateFormat df = new SimpleDateFormat(pattern, locale); - if (utc) { - df.setTimeZone(TimeZone.getTimeZone("UTC")); - } - DATE_FORMATS.put(pattern, df); - } - return DATE_FORMATS.get(pattern); - } - - private DateUtil() { - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/DateUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/DateUtil.kt new file mode 100644 index 000000000..bc33a1ede --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/DateUtil.kt @@ -0,0 +1,62 @@ +package fr.free.nrw.commons.utils + +import android.text.format.DateFormat.getBestDateTimePattern +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.HashMap +import java.util.Locale +import java.util.TimeZone + +/** + * Utility class for date formatting and parsing. + * TODO: Switch to DateTimeFormatter when minSdk = 26. + */ +object DateUtil { + + private val DATE_FORMATS: MutableMap = HashMap() + + @JvmStatic + @Synchronized + fun iso8601DateFormat(date: Date): String { + return getCachedDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT, true).format(date) + } + + @JvmStatic + @Synchronized + @Throws(ParseException::class) + fun iso8601DateParse(date: String): Date { + return getCachedDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT, true).parse(date) + } + + @JvmStatic + fun getMonthOnlyDateString(date: Date): String { + return getDateStringWithSkeletonPattern(date, "MMMM d") + } + + @JvmStatic + fun getExtraShortDateString(date: Date): String { + return getDateStringWithSkeletonPattern(date, "MMM d") + } + + @JvmStatic + @Synchronized + fun getDateStringWithSkeletonPattern(date: Date, pattern: String): String { + return getCachedDateFormat( + getBestDateTimePattern(Locale.getDefault(), pattern), + Locale.getDefault(), false + ).format(date) + } + + @JvmStatic + private fun getCachedDateFormat(pattern: String, locale: Locale, utc: Boolean): SimpleDateFormat { + if (!DATE_FORMATS.containsKey(pattern)) { + val df = SimpleDateFormat(pattern, locale) + if (utc) { + df.timeZone = TimeZone.getTimeZone("UTC") + } + DATE_FORMATS[pattern] = df + } + return DATE_FORMATS[pattern]!! + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/DeviceInfoUtil.java b/app/src/main/java/fr/free/nrw/commons/utils/DeviceInfoUtil.java deleted file mode 100644 index 5e01cc606..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/DeviceInfoUtil.java +++ /dev/null @@ -1,91 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.content.Context; -import android.os.Build; - -import java.util.HashMap; -import java.util.Map; - -import fr.free.nrw.commons.utils.model.ConnectionType; -import fr.free.nrw.commons.utils.model.NetworkConnectionType; - -import static fr.free.nrw.commons.utils.model.ConnectionType.CELLULAR; -import static fr.free.nrw.commons.utils.model.ConnectionType.CELLULAR_3G; -import static fr.free.nrw.commons.utils.model.ConnectionType.CELLULAR_4G; -import static fr.free.nrw.commons.utils.model.ConnectionType.NO_INTERNET; -import static fr.free.nrw.commons.utils.model.ConnectionType.WIFI_NETWORK; -import static fr.free.nrw.commons.utils.model.NetworkConnectionType.FOUR_G; -import static fr.free.nrw.commons.utils.model.NetworkConnectionType.THREE_G; -import static fr.free.nrw.commons.utils.model.NetworkConnectionType.TWO_G; -import static fr.free.nrw.commons.utils.model.NetworkConnectionType.UNKNOWN; -import static fr.free.nrw.commons.utils.model.NetworkConnectionType.WIFI; - -/** - * Util class to get any information about the user's device - * Ensure that any sensitive information like IMEI is not fetched/shared without user's consent - */ -public class DeviceInfoUtil { - private static final Map TYPE_MAPPING = new HashMap<>(); - - static { - TYPE_MAPPING.put(TWO_G, CELLULAR); - TYPE_MAPPING.put(THREE_G, CELLULAR_3G); - TYPE_MAPPING.put(FOUR_G, CELLULAR_4G); - TYPE_MAPPING.put(WIFI, WIFI_NETWORK); - TYPE_MAPPING.put(UNKNOWN, CELLULAR); - } - - /** - * Get network connection type - * @param context - * @return wifi/cellular-4g/cellular-3g/cellular-2g/no-internet - */ - public static ConnectionType getConnectionType(Context context) { - if (!NetworkUtils.isInternetConnectionEstablished(context)) { - return NO_INTERNET; - } - NetworkConnectionType networkType = NetworkUtils.getNetworkType(context); - ConnectionType deviceNetworkType = TYPE_MAPPING.get(networkType); - return deviceNetworkType == null ? CELLULAR : deviceNetworkType; - } - - /** - * Get Device manufacturer - * @return - */ - public static String getDeviceManufacturer() { - return Build.MANUFACTURER; - } - - /** - * Get Device model name - * @return - */ - public static String getDeviceModel() { - return Build.MODEL; - } - - /** - * Get Android version. Eg. 4.4.2 - * @return - */ - public static String getAndroidVersion() { - return Build.VERSION.RELEASE; - } - - /** - * Get API Level. Eg. 26 - * @return - */ - public static String getAPILevel() { - return Build.VERSION.SDK; - } - - /** - * Get Device. - * @return - */ - public static String getDevice() { - return Build.DEVICE; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/DeviceInfoUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/DeviceInfoUtil.kt new file mode 100644 index 000000000..05d71c7e1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/DeviceInfoUtil.kt @@ -0,0 +1,80 @@ +package fr.free.nrw.commons.utils + +import android.content.Context +import android.os.Build +import fr.free.nrw.commons.utils.model.ConnectionType +import fr.free.nrw.commons.utils.model.NetworkConnectionType + +/** + * Util class to get any information about the user's device + * Ensure that any sensitive information like IMEI is not fetched/shared without user's consent + */ +object DeviceInfoUtil { + private val TYPE_MAPPING = mapOf( + NetworkConnectionType.TWO_G to ConnectionType.CELLULAR, + NetworkConnectionType.THREE_G to ConnectionType.CELLULAR_3G, + NetworkConnectionType.FOUR_G to ConnectionType.CELLULAR_4G, + NetworkConnectionType.WIFI to ConnectionType.WIFI_NETWORK, + NetworkConnectionType.UNKNOWN to ConnectionType.CELLULAR + ) + + /** + * Get network connection type + * @param context + * @return wifi/cellular-4g/cellular-3g/cellular-2g/no-internet + */ + @JvmStatic + fun getConnectionType(context: Context): ConnectionType { + return if (!NetworkUtils.isInternetConnectionEstablished(context)) { + ConnectionType.NO_INTERNET + } else { + val networkType = NetworkUtils.getNetworkType(context) + TYPE_MAPPING[networkType] ?: ConnectionType.CELLULAR + } + } + + /** + * Get Device manufacturer + * @return + */ + @JvmStatic + fun getDeviceManufacturer(): String { + return Build.MANUFACTURER + } + + /** + * Get Device model name + * @return + */ + @JvmStatic + fun getDeviceModel(): String { + return Build.MODEL + } + + /** + * Get Android version. Eg. 4.4.2 + * @return + */ + @JvmStatic + fun getAndroidVersion(): String { + return Build.VERSION.RELEASE + } + + /** + * Get API Level. Eg. 26 + * @return + */ + @JvmStatic + fun getAPILevel(): String { + return Build.VERSION.SDK + } + + /** + * Get Device. + * @return + */ + @JvmStatic + fun getDevice(): String { + return Build.DEVICE + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/DialogUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/DialogUtil.kt index ed0487c6a..6d6fbb0a3 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/DialogUtil.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/DialogUtil.kt @@ -79,7 +79,6 @@ object DialogUtil { onPositiveBtnClick: Runnable?, onNegativeBtnClick: Runnable?, customView: View?, - cancelable: Boolean, ): AlertDialog? = createAndShowDialogSafely( activity = activity, @@ -90,7 +89,6 @@ object DialogUtil { onPositiveBtnClick = onPositiveBtnClick, onNegativeBtnClick = onNegativeBtnClick, customView = customView, - cancelable = cancelable, ) @JvmStatic @@ -103,7 +101,6 @@ object DialogUtil { onPositiveBtnClick: Runnable?, onNegativeBtnClick: Runnable?, customView: View?, - cancelable: Boolean, ): AlertDialog? = createAndShowDialogSafely( activity = activity, @@ -114,7 +111,6 @@ object DialogUtil { onPositiveBtnClick = onPositiveBtnClick, onNegativeBtnClick = onNegativeBtnClick, customView = customView, - cancelable = cancelable, ) @JvmStatic @@ -124,7 +120,6 @@ object DialogUtil { message: String?, positiveButtonText: String?, onPositiveBtnClick: Runnable?, - cancelable: Boolean, ): AlertDialog? = createAndShowDialogSafely( activity = activity, @@ -132,7 +127,6 @@ object DialogUtil { message = message, positiveButtonText = positiveButtonText, onPositiveBtnClick = onPositiveBtnClick, - cancelable = cancelable, ) /** @@ -156,7 +150,7 @@ object DialogUtil { onPositiveBtnClick: Runnable? = null, onNegativeBtnClick: Runnable? = null, customView: View? = null, - cancelable: Boolean = true, + cancelable: Boolean = false, ): AlertDialog? { /* If the custom view already has a parent, there is already a dialog showing with the view * This happens for on resume - return to avoid creating a second dialog - the first one diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ExecutorUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/ExecutorUtils.java deleted file mode 100644 index 889b31f2d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/ExecutorUtils.java +++ /dev/null @@ -1,31 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.os.Handler; -import android.os.Looper; - -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class ExecutorUtils { - - private static final Executor uiExecutor = command -> { - if (Looper.myLooper() == Looper.getMainLooper()) { - command.run(); - } else { - new Handler(Looper.getMainLooper()).post(command); - } - }; - - public static Executor uiExecutor() { - return uiExecutor; - } - - - private static final ExecutorService executor = Executors.newFixedThreadPool(3); - - public static ExecutorService get() { - return executor; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ExecutorUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/ExecutorUtils.kt new file mode 100644 index 000000000..981b19355 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/ExecutorUtils.kt @@ -0,0 +1,33 @@ +package fr.free.nrw.commons.utils + +import android.os.Handler +import android.os.Looper + +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +object ExecutorUtils { + + @JvmStatic + private val uiExecutor: Executor = Executor { command -> + if (Looper.myLooper() == Looper.getMainLooper()) { + command.run() + } else { + Handler(Looper.getMainLooper()).post(command) + } + } + + @JvmStatic + fun uiExecutor(): Executor { + return uiExecutor + } + + @JvmStatic + private val executor: ExecutorService = Executors.newFixedThreadPool(3) + + @JvmStatic + fun get(): ExecutorService { + return executor + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/FragmentUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/FragmentUtils.java deleted file mode 100644 index a01ff9251..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/FragmentUtils.java +++ /dev/null @@ -1,15 +0,0 @@ -package fr.free.nrw.commons.utils; - -import androidx.fragment.app.Fragment; - -public class FragmentUtils { - - /** - * Utility function to check whether the fragment UI is still active or not - * @param fragment - * @return - */ - public static boolean isFragmentUIActive(Fragment fragment) { - return fragment!=null && fragment.getActivity() != null && fragment.isAdded() && !fragment.isDetached() && !fragment.isRemoving(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/FragmentUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/FragmentUtils.kt new file mode 100644 index 000000000..4cdeecda2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/FragmentUtils.kt @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.utils + +import androidx.fragment.app.Fragment + +object FragmentUtils { + + /** + * Utility function to check whether the fragment UI is still active or not + * @param fragment + * @return Boolean + */ + @JvmStatic + fun isFragmentUIActive(fragment: Fragment?): Boolean { + return fragment != null && + fragment.activity != null && + fragment.isAdded && + !fragment.isDetached && + !fragment.isRemoving + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java deleted file mode 100644 index 99155a5e3..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java +++ /dev/null @@ -1,351 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.ProgressDialog; -import android.app.WallpaperManager; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.Color; -import android.net.Uri; -import android.os.Build; -import androidx.annotation.IntDef; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.exifinterface.media.ExifInterface; -import androidx.work.Data; -import androidx.work.OneTimeWorkRequest; -import androidx.work.WorkManager; -import com.facebook.common.executors.CallerThreadExecutor; -import com.facebook.common.references.CloseableReference; -import com.facebook.datasource.DataSource; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.imagepipeline.core.ImagePipeline; -import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; -import com.facebook.imagepipeline.image.CloseableImage; -import com.facebook.imagepipeline.request.ImageRequest; -import com.facebook.imagepipeline.request.ImageRequestBuilder; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.contributions.SetWallpaperWorker; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import java.io.IOException; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import timber.log.Timber; - -/** - * Created by bluesir9 on 3/10/17. - */ - -public class ImageUtils { - - /** - * Set 0th bit as 1 for dark image ie. 0001 - */ - public static final int IMAGE_DARK = 1 << 0; // 1 - /** - * Set 1st bit as 1 for blurry image ie. 0010 - */ - public static final int IMAGE_BLURRY = 1 << 1; // 2 - /** - * Set 2nd bit as 1 for duplicate image ie. 0100 - */ - public static final int IMAGE_DUPLICATE = 1 << 2; //4 - /** - * Set 3rd bit as 1 for image with different geo location ie. 1000 - */ - public static final int IMAGE_GEOLOCATION_DIFFERENT = 1 << 3; //8 - /** - * The parameter FILE_FBMD is returned from the class ReadFBMD if the uploaded image contains FBMD data else returns IMAGE_OK - * ie. 10000 - */ - public static final int FILE_FBMD = 1 << 4; - /** - * The parameter FILE_NO_EXIF is returned from the class EXIFReader if the uploaded image does not contains EXIF data else returns IMAGE_OK - * ie. 100000 - */ - public static final int FILE_NO_EXIF = 1 << 5; - public static final int IMAGE_OK = 0; - public static final int IMAGE_KEEP = -1; - public static final int IMAGE_WAIT = -2; - public static final int EMPTY_CAPTION = -3; - public static final int FILE_NAME_EXISTS = 1 << 6; - static final int NO_CATEGORY_SELECTED = -5; - - private static ProgressDialog progressDialogWallpaper; - - private static ProgressDialog progressDialogAvatar; - - @IntDef( - flag = true, - value = { - IMAGE_DARK, - IMAGE_BLURRY, - IMAGE_DUPLICATE, - IMAGE_OK, - IMAGE_KEEP, - IMAGE_WAIT, - EMPTY_CAPTION, - FILE_NAME_EXISTS, - NO_CATEGORY_SELECTED, - IMAGE_GEOLOCATION_DIFFERENT - } - ) - @Retention(RetentionPolicy.SOURCE) - public @interface Result { - } - - /** - * @return IMAGE_OK if image is not too dark - * IMAGE_DARK if image is too dark - */ - static @Result int checkIfImageIsTooDark(String imagePath) { - long millis = System.currentTimeMillis(); - try { - Bitmap bmp = new ExifInterface(imagePath).getThumbnailBitmap(); - if (bmp == null) { - bmp = BitmapFactory.decodeFile(imagePath); - } - - if (checkIfImageIsDark(bmp)) { - return IMAGE_DARK; - } - - } catch (Exception e) { - Timber.d(e, "Error while checking image darkness."); - } finally { - Timber.d("Checking image darkness took " + (System.currentTimeMillis() - millis) + " ms."); - } - return IMAGE_OK; - } - - /** - * @param geolocationOfFileString Geolocation of image. If geotag doesn't exists, then this will be an empty string - * @param latLng Location of wikidata item will be edited after upload - * @return false if image is neither dark nor blurry or if the input bitmapRegionDecoder provided is null - * true if geolocation of the image and wikidata item are different - */ - static boolean checkImageGeolocationIsDifferent(String geolocationOfFileString, LatLng latLng) { - Timber.d("Comparing geolocation of file with nearby place location"); - if (latLng == null) { // Means that geolocation for this image is not given - return false; // Since we don't know geolocation of file, we choose letting upload - } - - String[] geolocationOfFile = geolocationOfFileString.split("\\|"); - Double distance = LengthUtils.computeDistanceBetween( - new LatLng(Double.parseDouble(geolocationOfFile[0]),Double.parseDouble(geolocationOfFile[1]),0) - , latLng); - // Distance is more than 1 km, means that geolocation is wrong - return distance >= 1000; - } - - private static boolean checkIfImageIsDark(Bitmap bitmap) { - if (bitmap == null) { - Timber.e("Expected bitmap was null"); - return true; - } - - int bitmapWidth = bitmap.getWidth(); - int bitmapHeight = bitmap.getHeight(); - - int allPixelsCount = bitmapWidth * bitmapHeight; - int numberOfBrightPixels = 0; - int numberOfMediumBrightnessPixels = 0; - double brightPixelThreshold = 0.025 * allPixelsCount; - double mediumBrightPixelThreshold = 0.3 * allPixelsCount; - - for (int x = 0; x < bitmapWidth; x++) { - for (int y = 0; y < bitmapHeight; y++) { - int pixel = bitmap.getPixel(x, y); - int r = Color.red(pixel); - int g = Color.green(pixel); - int b = Color.blue(pixel); - - int secondMax = r > g ? r : g; - double max = (secondMax > b ? secondMax : b) / 255.0; - - int secondMin = r < g ? r : g; - double min = (secondMin < b ? secondMin : b) / 255.0; - - double luminance = ((max + min) / 2.0) * 100; - - int highBrightnessLuminance = 40; - int mediumBrightnessLuminance = 26; - - if (luminance < highBrightnessLuminance) { - if (luminance > mediumBrightnessLuminance) { - numberOfMediumBrightnessPixels++; - } - } else { - numberOfBrightPixels++; - } - - if (numberOfBrightPixels >= brightPixelThreshold || numberOfMediumBrightnessPixels >= mediumBrightPixelThreshold) { - return false; - } - } - } - return true; - } - - /** - * Downloads the image from the URL and sets it as the phone's wallpaper - * Fails silently if download or setting wallpaper fails. - * - * @param context context - * @param imageUrl Url of the image - */ - public static void setWallpaperFromImageUrl(Context context, Uri imageUrl) { - - enqueueSetWallpaperWork(context, imageUrl); - - } - - private static void createNotificationChannel(Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - CharSequence name = "Wallpaper Setting"; - String description = "Notifications for wallpaper setting progress"; - int importance = NotificationManager.IMPORTANCE_DEFAULT; - NotificationChannel channel = new NotificationChannel("set_wallpaper_channel", name, importance); - channel.setDescription(description); - NotificationManager notificationManager = context.getSystemService(NotificationManager.class); - notificationManager.createNotificationChannel(channel); - } - } - - - /** - * Calls the set avatar api to set the image url as user's avatar - * @param context - * @param url - * @param username - * @param okHttpJsonApiClient - * @param compositeDisposable - */ - public static void setAvatarFromImageUrl(Context context, String url, String username, - OkHttpJsonApiClient okHttpJsonApiClient, CompositeDisposable compositeDisposable) { - showSettingAvatarProgressBar(context); - - try { - compositeDisposable.add(okHttpJsonApiClient - .setAvatar(username, url) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - response -> { - if (response != null && response.getStatus().equals("200")) { - ViewUtil.showLongToast(context, context.getString(R.string.avatar_set_successfully)); - if (progressDialogAvatar != null && progressDialogAvatar.isShowing()) { - progressDialogAvatar.dismiss(); - } - } - }, - t -> { - Timber.e(t, "Setting Avatar Failed"); - ViewUtil.showLongToast(context, context.getString(R.string.avatar_set_unsuccessfully)); - if (progressDialogAvatar != null) { - progressDialogAvatar.cancel(); - } - } - )); - } - catch (Exception e){ - Timber.d(e+"success"); - ViewUtil.showLongToast(context, context.getString(R.string.avatar_set_unsuccessfully)); - if (progressDialogAvatar != null) { - progressDialogAvatar.cancel(); - } - } - - } - - public static void enqueueSetWallpaperWork(Context context, Uri imageUrl) { - createNotificationChannel(context); // Ensure the notification channel is created - - Data inputData = new Data.Builder() - .putString("imageUrl", imageUrl.toString()) - .build(); - - OneTimeWorkRequest setWallpaperWork = new OneTimeWorkRequest.Builder(SetWallpaperWorker.class) - .setInputData(inputData) - .build(); - - WorkManager.getInstance(context).enqueue(setWallpaperWork); - } - - - private static void showSettingWallpaperProgressBar(Context context) { - progressDialogWallpaper = ProgressDialog.show(context, context.getString(R.string.setting_wallpaper_dialog_title), - context.getString(R.string.setting_wallpaper_dialog_message), true); - } - - private static void showSettingAvatarProgressBar(Context context) { - progressDialogAvatar = ProgressDialog.show(context, context.getString(R.string.setting_avatar_dialog_title), - context.getString(R.string.setting_avatar_dialog_message), true); - } - - /** - * Result variable is a result of an or operation of all possible problems. Ie. if result - * is 0001 means IMAGE_DARK - * if result is 1100 IMAGE_DUPLICATE and IMAGE_GEOLOCATION_DIFFERENT - */ - public static String getErrorMessageForResult(Context context, @Result int result) { - StringBuilder errorMessage = new StringBuilder(); - if (result <= 0 ) { - Timber.d("No issues to warn user is found"); - } else { - Timber.d("Issues found to warn user"); - - errorMessage.append(context.getResources().getString(R.string.upload_problem_exist)); - - if ((IMAGE_DARK & result) != 0 ) { // We are checking image dark bit to see if that bit is set or not - errorMessage.append("\n - ").append(context.getResources().getString(R.string.upload_problem_image_dark)); - } - - if ((IMAGE_BLURRY & result) != 0 ) { - errorMessage.append("\n - ").append(context.getResources().getString(R.string.upload_problem_image_blurry)); - } - - if ((IMAGE_DUPLICATE & result) != 0 ) { - errorMessage.append("\n - ").append(context.getResources().getString(R.string.upload_problem_image_duplicate)); - } - - if ((IMAGE_GEOLOCATION_DIFFERENT & result) != 0 ) { - errorMessage.append("\n - ").append(context.getResources().getString(R.string.upload_problem_different_geolocation)); - } - - if ((FILE_FBMD & result) != 0) { - errorMessage.append("\n - ").append(context.getResources().getString(R.string.upload_problem_fbmd)); - } - - if ((FILE_NO_EXIF & result) != 0){ - errorMessage.append("\n - ").append(context.getResources().getString(R.string.internet_downloaded)); - } - - errorMessage.append("\n\n").append(context.getResources().getString(R.string.upload_problem_do_you_continue)); - } - - return errorMessage.toString(); - } - - /** - * Adds red border to a bitmap - * @param bitmap - * @param borderSize - * @param context - * @return - */ - public static Bitmap addRedBorder(Bitmap bitmap, int borderSize, Context context) { - Bitmap bmpWithBorder = Bitmap.createBitmap(bitmap.getWidth() + borderSize * 2, bitmap.getHeight() + borderSize * 2, bitmap.getConfig()); - Canvas canvas = new Canvas(bmpWithBorder); - canvas.drawColor(ContextCompat.getColor(context, R.color.deleteRed)); - canvas.drawBitmap(bitmap, borderSize, borderSize, null); - return bmpWithBorder; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.kt new file mode 100644 index 000000000..ebff3d054 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.kt @@ -0,0 +1,365 @@ +package fr.free.nrw.commons.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.ProgressDialog +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.net.Uri +import android.os.Build +import androidx.annotation.IntDef +import androidx.core.content.ContextCompat +import androidx.exifinterface.media.ExifInterface +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import fr.free.nrw.commons.R +import fr.free.nrw.commons.contributions.SetWallpaperWorker +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber + +/** + * Created by blueSir9 on 3/10/17. + */ + + +object ImageUtils { + + /** + * Set 0th bit as 1 for dark image ie. 0001 + */ + const val IMAGE_DARK = 1 shl 0 // 1 + + /** + * Set 1st bit as 1 for blurry image ie. 0010 + */ + const val IMAGE_BLURRY = 1 shl 1 // 2 + + /** + * Set 2nd bit as 1 for duplicate image ie. 0100 + */ + const val IMAGE_DUPLICATE = 1 shl 2 // 4 + + /** + * Set 3rd bit as 1 for image with different geo location ie. 1000 + */ + const val IMAGE_GEOLOCATION_DIFFERENT = 1 shl 3 // 8 + + /** + * The parameter FILE_FBMD is returned from the class ReadFBMD if the uploaded image contains + * FBMD data else returns IMAGE_OK + * ie. 10000 + */ + const val FILE_FBMD = 1 shl 4 // 16 + + /** + * The parameter FILE_NO_EXIF is returned from the class EXIFReader if the uploaded image does + * not contains EXIF data else returns IMAGE_OK + * ie. 100000 + */ + const val FILE_NO_EXIF = 1 shl 5 // 32 + + const val IMAGE_OK = 0 + const val IMAGE_KEEP = -1 + const val IMAGE_WAIT = -2 + const val EMPTY_CAPTION = -3 + const val FILE_NAME_EXISTS = 1 shl 6 // 64 + const val NO_CATEGORY_SELECTED = -5 + + private var progressDialogWallpaper: ProgressDialog? = null + + private var progressDialogAvatar: ProgressDialog? = null + + @IntDef( + flag = true, + value = [ + IMAGE_DARK, + IMAGE_BLURRY, + IMAGE_DUPLICATE, + IMAGE_OK, + IMAGE_KEEP, + IMAGE_WAIT, + EMPTY_CAPTION, + FILE_NAME_EXISTS, + NO_CATEGORY_SELECTED, + IMAGE_GEOLOCATION_DIFFERENT + ] + ) + @Retention + annotation class Result + + /** + * @return IMAGE_OK if image is not too dark + * IMAGE_DARK if image is too dark + */ + @JvmStatic + fun checkIfImageIsTooDark(imagePath: String): Int { + val millis = System.currentTimeMillis() + return try { + var bmp = ExifInterface(imagePath).thumbnailBitmap + if (bmp == null) { + bmp = BitmapFactory.decodeFile(imagePath) + } + + if (checkIfImageIsDark(bmp)) { + IMAGE_DARK + } else { + IMAGE_OK + } + } catch (e: Exception) { + Timber.d(e, "Error while checking image darkness.") + IMAGE_OK + } finally { + Timber.d("Checking image darkness took ${System.currentTimeMillis() - millis} ms.") + } + } + + /** + * @param geolocationOfFileString Geolocation of image. If geotag doesn't exists, then this will + * be an empty string + * @param latLng Location of wikidata item will be edited after upload + * @return false if image is neither dark nor blurry or if the input bitmapRegionDecoder provide + * d is null true if geolocation of the image and wikidata item are different + */ + @JvmStatic + fun checkImageGeolocationIsDifferent(geolocationOfFileString: String, latLng: LatLng?): Boolean { + Timber.d("Comparing geolocation of file with nearby place location") + if (latLng == null) { // Means that geolocation for this image is not given + return false // Since we don't know geolocation of file, we choose letting upload + } + + val geolocationOfFile = geolocationOfFileString.split("|") + val distance = LengthUtils.computeDistanceBetween( + LatLng(geolocationOfFile[0].toDouble(), geolocationOfFile[1].toDouble(), 0.0F), + latLng + ) + // Distance is more than 1 km, means that geolocation is wrong + return distance >= 1000 + } + + @JvmStatic + private fun checkIfImageIsDark(bitmap: Bitmap?): Boolean { + if (bitmap == null) { + Timber.e("Expected bitmap was null") + return true + } + + val bitmapWidth = bitmap.width + val bitmapHeight = bitmap.height + + val allPixelsCount = bitmapWidth * bitmapHeight + var numberOfBrightPixels = 0 + var numberOfMediumBrightnessPixels = 0 + val brightPixelThreshold = 0.025 * allPixelsCount + val mediumBrightPixelThreshold = 0.3 * allPixelsCount + + for (x in 0 until bitmapWidth) { + for (y in 0 until bitmapHeight) { + val pixel = bitmap.getPixel(x, y) + val r = Color.red(pixel) + val g = Color.green(pixel) + val b = Color.blue(pixel) + + val max = maxOf(r, g, b) / 255.0 + val min = minOf(r, g, b) / 255.0 + + val luminance = ((max + min) / 2.0) * 100 + + val highBrightnessLuminance = 40 + val mediumBrightnessLuminance = 26 + + if (luminance < highBrightnessLuminance) { + if (luminance > mediumBrightnessLuminance) { + numberOfMediumBrightnessPixels++ + } + } else { + numberOfBrightPixels++ + } + + if (numberOfBrightPixels >= brightPixelThreshold || numberOfMediumBrightnessPixels >= mediumBrightPixelThreshold) { + return false + } + } + } + return true + } + + /** + * Downloads the image from the URL and sets it as the phone's wallpaper + * Fails silently if download or setting wallpaper fails. + * + * @param context context + * @param imageUrl Url of the image + */ + @JvmStatic + fun setWallpaperFromImageUrl(context: Context, imageUrl: Uri) { + enqueueSetWallpaperWork(context, imageUrl) + } + + @JvmStatic + private fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = "Wallpaper Setting" + val description = "Notifications for wallpaper setting progress" + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel("set_wallpaper_channel", name, importance).apply { + this.description = description + } + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + } + + /** + * Calls the set avatar api to set the image url as user's avatar + * @param context + * @param url + * @param username + * @param okHttpJsonApiClient + * @param compositeDisposable + */ + @JvmStatic + fun setAvatarFromImageUrl( + context: Context, + url: String, + username: String, + okHttpJsonApiClient: OkHttpJsonApiClient, + compositeDisposable: CompositeDisposable + ) { + showSettingAvatarProgressBar(context) + + try { + compositeDisposable.add( + okHttpJsonApiClient + .setAvatar(username, url) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + if (response?.status == "200") { + ViewUtil.showLongToast(context, context.getString(R.string.avatar_set_successfully)) + progressDialogAvatar?.dismiss() + } + }, + { t -> + Timber.e(t, "Setting Avatar Failed") + ViewUtil.showLongToast(context, context.getString(R.string.avatar_set_unsuccessfully)) + progressDialogAvatar?.cancel() + } + ) + ) + } catch (e: Exception) { + Timber.d("$e success") + ViewUtil.showLongToast(context, context.getString(R.string.avatar_set_unsuccessfully)) + progressDialogAvatar?.cancel() + } + } + + @JvmStatic + fun enqueueSetWallpaperWork(context: Context, imageUrl: Uri) { + createNotificationChannel(context) // Ensure the notification channel is created + + val inputData = Data.Builder() + .putString("imageUrl", imageUrl.toString()) + .build() + + val setWallpaperWork = OneTimeWorkRequest.Builder(SetWallpaperWorker::class.java) + .setInputData(inputData) + .build() + + WorkManager.getInstance(context).enqueue(setWallpaperWork) + } + + @JvmStatic + private fun showSettingWallpaperProgressBar(context: Context) { + progressDialogWallpaper = ProgressDialog.show( + context, + context.getString(R.string.setting_wallpaper_dialog_title), + context.getString(R.string.setting_wallpaper_dialog_message), + true, + false + ) + } + + @JvmStatic + private fun showSettingAvatarProgressBar(context: Context) { + progressDialogAvatar = ProgressDialog.show( + context, + context.getString(R.string.setting_avatar_dialog_title), + context.getString(R.string.setting_avatar_dialog_message), + true, + false + ) + } + + /** + * Adds red border to bitmap with specified border size + * * @param bitmap + * * @param borderSize + * * @param context + * * @return + */ + @JvmStatic + fun addRedBorder(bitmap: Bitmap, borderSize: Int, context: Context): Bitmap { + val bmpWithBorder = Bitmap.createBitmap( + bitmap.width + borderSize * 2, + bitmap.height + borderSize * 2, + bitmap.config + ) + val canvas = Canvas(bmpWithBorder) + canvas.drawColor(ContextCompat.getColor(context, R.color.deleteRed)) + canvas.drawBitmap(bitmap, borderSize.toFloat(), borderSize.toFloat(), null) + return bmpWithBorder + } + + /** + * Result variable is a result of an or operation of all possible problems. Ie. if result + * is 0001 means IMAGE_DARK + * if result is 1100 IMAGE_DUPLICATE and IMAGE_GEOLOCATION_DIFFERENT + */ + @JvmStatic + fun getErrorMessageForResult(context: Context, @Result result: Int): String { + val errorMessage = StringBuilder() + if (result <= 0) { + Timber.d("No issues to warn user are found") + } else { + Timber.d("Issues found to warn user") + errorMessage.append(context.getString(R.string.upload_problem_exist)) + + if (result and IMAGE_DARK != 0) { + errorMessage.append("\n - ") + .append(context.getString(R.string.upload_problem_image_dark)) + } + if (result and IMAGE_BLURRY != 0) { + errorMessage.append("\n - ") + .append(context.getString(R.string.upload_problem_image_blurry)) + } + if (result and IMAGE_DUPLICATE != 0) { + errorMessage.append("\n - "). + append(context.getString(R.string.upload_problem_image_duplicate)) + } + if (result and IMAGE_GEOLOCATION_DIFFERENT != 0) { + errorMessage.append("\n - ") + .append(context.getString(R.string.upload_problem_different_geolocation)) + } + if (result and FILE_FBMD != 0) { + errorMessage.append("\n - ") + .append(context.getString(R.string.upload_problem_fbmd)) + } + if (result and FILE_NO_EXIF != 0) { + errorMessage.append("\n - ") + .append(context.getString(R.string.internet_downloaded)) + } + errorMessage.append("\n\n") + .append(context.getString(R.string.upload_problem_do_you_continue)) + } + return errorMessage.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.java b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.java deleted file mode 100644 index 634a73ad2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.java +++ /dev/null @@ -1,30 +0,0 @@ -package fr.free.nrw.commons.utils; - -import fr.free.nrw.commons.location.LatLng; -import io.reactivex.Single; -import io.reactivex.schedulers.Schedulers; -import javax.inject.Inject; -import javax.inject.Singleton; - -@Singleton -public class ImageUtilsWrapper { - - @Inject - public ImageUtilsWrapper() { - - } - - public Single checkIfImageIsTooDark(String bitmapPath) { - return Single.fromCallable(() -> ImageUtils.checkIfImageIsTooDark(bitmapPath)) - .subscribeOn(Schedulers.computation()); - } - - public Single checkImageGeolocationIsDifferent(String geolocationOfFileString, - LatLng latLng) { - return Single.fromCallable( - () -> ImageUtils.checkImageGeolocationIsDifferent(geolocationOfFileString, latLng)) - .subscribeOn(Schedulers.computation()) - .map(isDifferent -> isDifferent ? ImageUtils.IMAGE_GEOLOCATION_DIFFERENT - : ImageUtils.IMAGE_OK); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.kt b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.kt new file mode 100644 index 000000000..8393dc652 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtilsWrapper.kt @@ -0,0 +1,29 @@ +package fr.free.nrw.commons.utils + +import fr.free.nrw.commons.location.LatLng +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ImageUtilsWrapper @Inject constructor() { + + fun checkIfImageIsTooDark(bitmapPath: String): Single { + return Single.fromCallable { ImageUtils.checkIfImageIsTooDark(bitmapPath) } + .subscribeOn(Schedulers.computation()) + } + + fun checkImageGeolocationIsDifferent( + geolocationOfFileString: String, + latLng: LatLng? + ): Single { + return Single.fromCallable { + ImageUtils.checkImageGeolocationIsDifferent(geolocationOfFileString, latLng) + } + .subscribeOn(Schedulers.computation()) + .map { isDifferent -> + if (isDifferent) ImageUtils.IMAGE_GEOLOCATION_DIFFERENT else ImageUtils.IMAGE_OK + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/utils/LangCodeUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/LangCodeUtils.java deleted file mode 100644 index 73bd5c02b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/LangCodeUtils.java +++ /dev/null @@ -1,39 +0,0 @@ -package fr.free.nrw.commons.utils; -import android.content.Context; -import android.content.res.Configuration; -import android.content.res.Resources; -import java.util.Locale; - -/** - * Utilities class for miscellaneous strings - */ -public class LangCodeUtils { - /** - * Replaces the deprecated ISO-639 language codes used by Android with the updated ISO-639-1. - * @param code Language code you want to update. - * @return Updated language code. If not in the "deprecated list" returns the same code. - */ - public static String fixLanguageCode(String code) { - if (code.equalsIgnoreCase("iw")) { - return "he"; - } else if (code.equalsIgnoreCase("in")) { - return "id"; - } else if (code.equalsIgnoreCase("ji")) { - return "yi"; - } else { - return code; - } - } - - /** - * Returns configuration for locale of - * our choice regardless of user's device settings - */ - public static Resources getLocalizedResources(Context context, Locale desiredLocale) { - Configuration conf = context.getResources().getConfiguration(); - conf = new Configuration(conf); - conf.setLocale(desiredLocale); - Context localizedContext = context.createConfigurationContext(conf); - return localizedContext.getResources(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/LangCodeUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/LangCodeUtils.kt new file mode 100644 index 000000000..5ef21a735 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/LangCodeUtils.kt @@ -0,0 +1,40 @@ +package fr.free.nrw.commons.utils + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources +import java.util.Locale + +/** + * Utilities class for miscellaneous strings + */ +object LangCodeUtils { + + /** + * Replaces the deprecated ISO-639 language codes used by Android with the updated ISO-639-1. + * @param code Language code you want to update. + * @return Updated language code. If not in the "deprecated list" returns the same code. + */ + @JvmStatic + fun fixLanguageCode(code: String): String { + return when (code.lowercase()) { + "iw" -> "he" + "in" -> "id" + "ji" -> "yi" + else -> code + } + } + + /** + * Returns configuration for locale of + * our choice regardless of user's device settings + */ + @JvmStatic + fun getLocalizedResources(context: Context, desiredLocale: Locale): Resources { + val conf = Configuration(context.resources.configuration).apply { + setLocale(desiredLocale) + } + val localizedContext = context.createConfigurationContext(conf) + return localizedContext.resources + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/LayoutUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/LayoutUtils.java deleted file mode 100644 index 76c52527b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/LayoutUtils.java +++ /dev/null @@ -1,38 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.app.Activity; -import android.content.Context; -import android.util.DisplayMetrics; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; - -public class LayoutUtils { - - /** - * Can be used for keeping aspect radios suggested by material guidelines. See: - * https://material.io/design/layout/spacing-methods.html#containers-aspect-ratios - * In some cases we don't know exact width, for such cases this method measures - * width and sets height by multiplying the width with height. - * @param rate Aspect ratios, ie 1 for 1:1. (width * rate = height) - * @param view view to change height - */ - public static void setLayoutHeightAllignedToWidth(double rate, View view) { - ViewTreeObserver vto = view.getViewTreeObserver(); - vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - view.getViewTreeObserver().removeOnGlobalLayoutListener(this); - ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); - layoutParams.height = (int) (view.getWidth() * rate); - view.setLayoutParams(layoutParams); - } - }); - } - - public static double getScreenWidth(Context context, double rate) { - DisplayMetrics displayMetrics = new DisplayMetrics(); - ((Activity)context).getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - return displayMetrics.widthPixels * rate; - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/utils/LayoutUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/LayoutUtils.kt new file mode 100644 index 000000000..71e6697f7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/LayoutUtils.kt @@ -0,0 +1,47 @@ +package fr.free.nrw.commons.utils + +import android.app.Activity +import android.content.Context +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewTreeObserver + +/** + * Utility class for layout-related operations. + */ +object LayoutUtils { + + /** + * Can be used for keeping aspect ratios suggested by material guidelines. See: + * https://material.io/design/layout/spacing-methods.html#containers-aspect-ratios + * In some cases, we don't know the exact width, for such cases this method measures + * width and sets height by multiplying the width with height. + * @param rate Aspect ratios, i.e., 1 for 1:1 (width * rate = height) + * @param view View to change height + */ + @JvmStatic + fun setLayoutHeightAlignedToWidth(rate: Double, view: View) { + val vto = view.viewTreeObserver + vto.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + val layoutParams = view.layoutParams + layoutParams.height = (view.width * rate).toInt() + view.layoutParams = layoutParams + } + }) + } + + /** + * Calculates and returns the screen width multiplied by the provided rate. + * @param context Context used to access display metrics. + * @param rate Multiplier for screen width. + * @return Calculated screen width multiplied by the rate. + */ + @JvmStatic + fun getScreenWidth(context: Context, rate: Double): Double { + val displayMetrics = DisplayMetrics() + (context as Activity).windowManager.defaultDisplay.getMetrics(displayMetrics) + return displayMetrics.widthPixels * rate + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/utils/LengthUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/LengthUtils.java deleted file mode 100644 index 0ca61a1d9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/LengthUtils.java +++ /dev/null @@ -1,145 +0,0 @@ -package fr.free.nrw.commons.utils; - -import androidx.annotation.NonNull; - -import java.text.NumberFormat; - -import fr.free.nrw.commons.location.LatLng; - -public class LengthUtils { - /** - * Returns a formatted distance string between two points. - * - * @param point1 LatLng type point1 - * @param point2 LatLng type point2 - * @return string distance - */ - public static String formatDistanceBetween(LatLng point1, LatLng point2) { - if (point1 == null || point2 == null) { - return null; - } - - int distance = (int) Math.round(computeDistanceBetween(point1, point2)); - return formatDistance(distance); - } - - /** - * Format a distance (in meters) as a string - * Example: 140 -> "140m" - * 3841 -> "3.8km" - * - * @param distance Distance, in meters - * @return A string representing the distance - * @throws IllegalArgumentException If distance is negative - */ - public static String formatDistance(int distance) { - if (distance < 0) { - throw new IllegalArgumentException("Distance must be non-negative"); - } - - NumberFormat numberFormat = NumberFormat.getNumberInstance(); - - // Adjust to km if distance is over 1000m (1km) - if (distance >= 1000) { - numberFormat.setMaximumFractionDigits(1); - return numberFormat.format(distance / 1000.0) + "km"; - } - - // Otherwise just return in meters - return numberFormat.format(distance) + "m"; - } - - /** - * Computes the distance between two points. - * - * @param point1 LatLng type point1 - * @param point2 LatLng type point2 - * @return distance between the points in meters - * @throws NullPointerException if one or both the points are null - */ - public static double computeDistanceBetween(@NonNull LatLng point1, @NonNull LatLng point2) { - return computeAngleBetween(point1, point2) * 6371009.0D; // Earth's radius in meter - } - - /** - * Computes angle between two points - * - * @param point1 one of the two end points - * @param point2 one of the two end points - * @return Angle in radius - * @throws NullPointerException if one or both the points are null - */ - private static double computeAngleBetween(@NonNull LatLng point1, @NonNull LatLng point2) { - return distanceRadians( - Math.toRadians(point1.getLatitude()), - Math.toRadians(point1.getLongitude()), - Math.toRadians(point2.getLatitude()), - Math.toRadians(point2.getLongitude()) - ); - } - - /** - * Computes arc length between 2 points - * - * @param lat1 Latitude of point A - * @param lng1 Longitude of point A - * @param lat2 Latitude of point B - * @param lng2 Longitude of point B - * @return Arc length between the points - */ - private static double distanceRadians(double lat1, double lng1, double lat2, double lng2) { - return arcHav(havDistance(lat1, lat2, lng1 - lng2)); - } - - /** - * Computes inverse of haversine - * - * @param x Angle in radian - * @return Inverse of haversine - */ - private static double arcHav(double x) { - return 2.0D * Math.asin(Math.sqrt(x)); - } - - /** - * Computes distance between two points that are on same Longitude - * - * @param lat1 Latitude of point A - * @param lat2 Latitude of point B - * @param longitude Longitude on which they lie - * @return Arc length between points - */ - private static double havDistance(double lat1, double lat2, double longitude) { - return hav(lat1 - lat2) + hav(longitude) * Math.cos(lat1) * Math.cos(lat2); - } - - /** - * Computes haversine - * - * @param x Angle in radians - * @return Haversine of x - */ - private static double hav(double x) { - double sinHalf = Math.sin(x * 0.5D); - return sinHalf * sinHalf; - } - - /** - * Computes bearing between the two given points - * - * @see Bearing - * @param point1 Coordinates of first point - * @param point2 Coordinates of second point - * @return Bearing between the two end points in degrees - * @throws NullPointerException if one or both the points are null - */ - public static double computeBearing(@NonNull LatLng point1, @NonNull LatLng point2) { - double diffLongitute = Math.toRadians(point2.getLongitude() - point1.getLongitude()); - double lat1 = Math.toRadians(point1.getLatitude()); - double lat2 = Math.toRadians(point2.getLatitude()); - double y = Math.sin(diffLongitute) * Math.cos(lat2); - double x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(diffLongitute); - double bearing = Math.atan2(y, x); - return (Math.toDegrees(bearing) + 360) % 360; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/LengthUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/LengthUtils.kt new file mode 100644 index 000000000..48cf1a020 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/LengthUtils.kt @@ -0,0 +1,156 @@ +package fr.free.nrw.commons.utils + +import java.text.NumberFormat +import fr.free.nrw.commons.location.LatLng +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +object LengthUtils { + /** + * Returns a formatted distance string between two points. + * + * @param point1 LatLng type point1 + * @param point2 LatLng type point2 + * @return string distance + */ + @JvmStatic + fun formatDistanceBetween(point1: LatLng?, point2: LatLng?): String? { + if (point1 == null || point2 == null) { + return null + } + + val distance = computeDistanceBetween(point1, point2).roundToInt() + return formatDistance(distance) + } + + /** + * Format a distance (in meters) as a string + * Example: 140 -> "140m" + * 3841 -> "3.8km" + * + * @param distance Distance, in meters + * @return A string representing the distance + * @throws IllegalArgumentException If distance is negative + */ + @JvmStatic + fun formatDistance(distance: Int): String { + if (distance < 0) { + throw IllegalArgumentException("Distance must be non-negative") + } + + val numberFormat = NumberFormat.getNumberInstance() + + // Adjust to km if distance is over 1000m (1km) + return if (distance >= 1000) { + numberFormat.maximumFractionDigits = 1 + "${numberFormat.format(distance / 1000.0)}km" + } else { + "${numberFormat.format(distance)}m" + } + } + + /** + * Computes the distance between two points. + * + * @param point1 LatLng type point1 + * @param point2 LatLng type point2 + * @return distance between the points in meters + * @throws NullPointerException if one or both the points are null + */ + @JvmStatic + fun computeDistanceBetween(point1: LatLng, point2: LatLng): Double { + return computeAngleBetween(point1, point2) * 6371009.0 // Earth's radius in meters + } + + /** + * Computes angle between two points + * + * @param point1 one of the two end points + * @param point2 one of the two end points + * @return Angle in radians + * @throws NullPointerException if one or both the points are null + */ + @JvmStatic + private fun computeAngleBetween(point1: LatLng, point2: LatLng): Double { + return distanceRadians( + Math.toRadians(point1.latitude), + Math.toRadians(point1.longitude), + Math.toRadians(point2.latitude), + Math.toRadians(point2.longitude) + ) + } + + /** + * Computes arc length between 2 points + * + * @param lat1 Latitude of point A + * @param lng1 Longitude of point A + * @param lat2 Latitude of point B + * @param lng2 Longitude of point B + * @return Arc length between the points + */ + @JvmStatic + private fun distanceRadians(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double { + return arcHav(havDistance(lat1, lat2, lng1 - lng2)) + } + + /** + * Computes inverse of haversine + * + * @param x Angle in radian + * @return Inverse of haversine + */ + @JvmStatic + private fun arcHav(x: Double): Double { + return 2.0 * asin(sqrt(x)) + } + + /** + * Computes distance between two points that are on same Longitude + * + * @param lat1 Latitude of point A + * @param lat2 Latitude of point B + * @param longitude Longitude on which they lie + * @return Arc length between points + */ + @JvmStatic + private fun havDistance(lat1: Double, lat2: Double, longitude: Double): Double { + return hav(lat1 - lat2) + hav(longitude) * cos(lat1) * cos(lat2) + } + + /** + * Computes haversine + * + * @param x Angle in radians + * @return Haversine of x + */ + @JvmStatic + private fun hav(x: Double): Double { + val sinHalf = sin(x * 0.5) + return sinHalf * sinHalf + } + + /** + * Computes bearing between the two given points + * + * @see Bearing + * @param point1 Coordinates of first point + * @param point2 Coordinates of second point + * @return Bearing between the two end points in degrees + * @throws NullPointerException if one or both the points are null + */ + @JvmStatic + fun computeBearing(point1: LatLng, point2: LatLng): Double { + val diffLongitude = Math.toRadians(point2.longitude - point1.longitude) + val lat1 = Math.toRadians(point1.latitude) + val lat2 = Math.toRadians(point2.latitude) + val y = sin(diffLongitude) * cos(lat2) + val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(diffLongitude) + val bearing = atan2(y, x) + return (Math.toDegrees(bearing) + 360) % 360 + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.java deleted file mode 100644 index 01a885538..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.java +++ /dev/null @@ -1,58 +0,0 @@ -package fr.free.nrw.commons.utils; - -import fr.free.nrw.commons.location.LatLng; -import timber.log.Timber; - -public class LocationUtils { - public static final double RADIUS_OF_EARTH_KM = 6371.0; // Earth's radius in kilometers - - public static LatLng deriveUpdatedLocationFromSearchQuery(String customQuery) { - LatLng latLng = null; - final int indexOfPrefix = customQuery.indexOf("Point("); - if (indexOfPrefix == -1) { - Timber.e("Invalid prefix index - Seems like user has entered an invalid query"); - return latLng; - } - final int indexOfSuffix = customQuery.indexOf(")\"", indexOfPrefix); - if (indexOfSuffix == -1) { - Timber.e("Invalid suffix index - Seems like user has entered an invalid query"); - return latLng; - } - String latLngString = customQuery.substring(indexOfPrefix+"Point(".length(), indexOfSuffix); - if (latLngString.isEmpty()) { - return null; - } - - String latLngArray[] = latLngString.split(" "); - if (latLngArray.length != 2) { - return null; - } - - try { - latLng = new LatLng(Double.parseDouble(latLngArray[1].trim()), - Double.parseDouble(latLngArray[0].trim()), 1f); - }catch (Exception e){ - Timber.e("Error while parsing user entered lat long: %s", e); - } - - return latLng; - } - - - public static double calculateDistance(double lat1, double lon1, double lat2, double lon2) { - double lat1Rad = Math.toRadians(lat1); - double lon1Rad = Math.toRadians(lon1); - double lat2Rad = Math.toRadians(lat2); - double lon2Rad = Math.toRadians(lon2); - - // Haversine formula - double dlon = lon2Rad - lon1Rad; - double dlat = lat2Rad - lat1Rad; - double a = Math.pow(Math.sin(dlat / 2), 2) + Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.pow(Math.sin(dlon / 2), 2); - double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - - double distance = RADIUS_OF_EARTH_KM * c; - - return distance; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.kt new file mode 100644 index 000000000..2df42270e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/LocationUtils.kt @@ -0,0 +1,63 @@ +package fr.free.nrw.commons.utils + +import fr.free.nrw.commons.location.LatLng +import timber.log.Timber +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +object LocationUtils { + const val RADIUS_OF_EARTH_KM = 6371.0 // Earth's radius in kilometers + + @JvmStatic + fun deriveUpdatedLocationFromSearchQuery(customQuery: String): LatLng? { + var latLng: LatLng? = null + val indexOfPrefix = customQuery.indexOf("Point(") + if (indexOfPrefix == -1) { + Timber.e("Invalid prefix index - Seems like user has entered an invalid query") + return latLng + } + val indexOfSuffix = customQuery.indexOf(")\"", indexOfPrefix) + if (indexOfSuffix == -1) { + Timber.e("Invalid suffix index - Seems like user has entered an invalid query") + return latLng + } + val latLngString = customQuery.substring(indexOfPrefix + "Point(".length, indexOfSuffix) + if (latLngString.isEmpty()) { + return null + } + + val latLngArray = latLngString.split(" ") + if (latLngArray.size != 2) { + return null + } + + try { + latLng = LatLng(latLngArray[1].trim().toDouble(), + latLngArray[0].trim().toDouble(), 1f) + } catch (e: Exception) { + Timber.e("Error while parsing user entered lat long: %s", e) + } + + return latLng + } + + @JvmStatic + fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val lat1Rad = Math.toRadians(lat1) + val lon1Rad = Math.toRadians(lon1) + val lat2Rad = Math.toRadians(lat2) + val lon2Rad = Math.toRadians(lon2) + + // Haversine formula + val dlon = lon2Rad - lon1Rad + val dlat = lat2Rad - lat1Rad + val a = Math.pow( + sin(dlat / 2), 2.0) + cos(lat1Rad) * cos(lat2Rad) * Math.pow(sin(dlon / 2), 2.0 + ) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + return RADIUS_OF_EARTH_KM * c + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/MapUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/MapUtils.java deleted file mode 100644 index d3b5bd0e2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/MapUtils.java +++ /dev/null @@ -1,33 +0,0 @@ -package fr.free.nrw.commons.utils; - -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.location.LocationUpdateListener; -import timber.log.Timber; - -public class MapUtils { - public static final float ZOOM_LEVEL = 14f; - public static final double CAMERA_TARGET_SHIFT_FACTOR_PORTRAIT = 0.005; - public static final double CAMERA_TARGET_SHIFT_FACTOR_LANDSCAPE = 0.004; - public static final String NETWORK_INTENT_ACTION = "android.net.conn.CONNECTIVITY_CHANGE"; - public static final float ZOOM_OUT = 0f; - - public static final LatLng defaultLatLng = new fr.free.nrw.commons.location.LatLng(51.50550,-0.07520,1f); - - public static void registerUnregisterLocationListener(final boolean removeLocationListener, LocationServiceManager locationManager, LocationUpdateListener locationUpdateListener) { - try { - if (removeLocationListener) { - locationManager.unregisterLocationManager(); - locationManager.removeLocationListener(locationUpdateListener); - Timber.d("Location service manager unregistered and removed"); - } else { - locationManager.addLocationListener(locationUpdateListener); - locationManager.registerLocationManager(); - Timber.d("Location service manager added and registered"); - } - }catch (final Exception e){ - Timber.e(e); - //Broadcasts are tricky, should be catchedonR - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/MapUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/MapUtils.kt new file mode 100644 index 000000000..adc3a5d90 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/MapUtils.kt @@ -0,0 +1,39 @@ +package fr.free.nrw.commons.utils + +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.location.LocationUpdateListener +import timber.log.Timber + +object MapUtils { + const val ZOOM_LEVEL = 14f + const val CAMERA_TARGET_SHIFT_FACTOR_PORTRAIT = 0.005 + const val CAMERA_TARGET_SHIFT_FACTOR_LANDSCAPE = 0.004 + const val NETWORK_INTENT_ACTION = "android.net.conn.CONNECTIVITY_CHANGE" + const val ZOOM_OUT = 0f + + @JvmStatic + val defaultLatLng = LatLng(51.50550, -0.07520, 1f) + + @JvmStatic + fun registerUnregisterLocationListener( + removeLocationListener: Boolean, + locationManager: LocationServiceManager, + locationUpdateListener: LocationUpdateListener + ) { + try { + if (removeLocationListener) { + locationManager.unregisterLocationManager() + locationManager.removeLocationListener(locationUpdateListener) + Timber.d("Location service manager unregistered and removed") + } else { + locationManager.addLocationListener(locationUpdateListener) + locationManager.registerLocationManager() + Timber.d("Location service manager added and registered") + } + } catch (e: Exception) { + Timber.e(e) + // Broadcasts are tricky, should be caught on onR + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.java b/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.java deleted file mode 100644 index 8eb875bb5..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.java +++ /dev/null @@ -1,29 +0,0 @@ -package fr.free.nrw.commons.utils; - -import org.apache.commons.lang3.StringUtils; - -import java.util.ArrayList; -import java.util.List; - -public class MediaDataExtractorUtil { - /** - * Extracts a list of categories from | separated category string - * - * @param source - * @return - */ - public static List extractCategoriesFromList(String source) { - if (StringUtils.isBlank(source)) { - return new ArrayList<>(); - } - String[] cats = source.split("\\|"); - List categories = new ArrayList<>(); - for (String category : cats) { - if (!StringUtils.isBlank(category.trim())) { - categories.add(category); - } - } - return categories; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.kt new file mode 100644 index 000000000..93cdabbfc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/MediaDataExtractorUtil.kt @@ -0,0 +1,25 @@ +package fr.free.nrw.commons.utils + +object MediaDataExtractorUtil { + + /** + * Extracts a list of categories from | separated category string + * + * @param source + * @return + */ + @JvmStatic + fun extractCategoriesFromList(source: String?): List { + if (source.isNullOrBlank()) { + return emptyList() + } + val cats = source.split("|") + val categories = mutableListOf() + for (category in cats) { + if (category.trim().isNotBlank()) { + categories.add(category) + } + } + return categories + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/NearbyFABUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/NearbyFABUtils.java deleted file mode 100644 index bc6e6883f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/NearbyFABUtils.java +++ /dev/null @@ -1,51 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; - -import androidx.coordinatorlayout.widget.CoordinatorLayout; - -import com.google.android.material.floatingactionbutton.FloatingActionButton; - -public class NearbyFABUtils { - /* - * Add anchors back before making them visible again. - * */ - public static void addAnchorToBigFABs(FloatingActionButton floatingActionButton, int anchorID) { - CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams - (ViewGroup.LayoutParams.WRAP_CONTENT,ViewGroup.LayoutParams.WRAP_CONTENT); - params.setAnchorId(anchorID); - params.anchorGravity = Gravity.TOP|Gravity.RIGHT|Gravity.END; - floatingActionButton.setLayoutParams(params); - } - - /* - * Add anchors back before making them visible again. Big and small fabs have different anchor - * gravities, therefore the are two methods. - * */ - public static void addAnchorToSmallFABs(FloatingActionButton floatingActionButton, int anchorID) { - CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams - (ViewGroup.LayoutParams.WRAP_CONTENT,ViewGroup.LayoutParams.WRAP_CONTENT); - params.setAnchorId(anchorID); - params.anchorGravity = Gravity.CENTER_HORIZONTAL; - floatingActionButton.setLayoutParams(params); - } - - /* - * We are not able to hide FABs without removing anchors, this method removes anchors - * */ - public static void removeAnchorFromFAB(FloatingActionButton floatingActionButton) { - //get rid of anchors - //Somehow this was the only way https://stackoverflow.com/questions/32732932 - // /floatingactionbutton-visible-for-sometime-even-if-visibility-is-set-to-gone - CoordinatorLayout.LayoutParams param = (CoordinatorLayout.LayoutParams) floatingActionButton - .getLayoutParams(); - param.setAnchorId(View.NO_ID); - // If we don't set them to zero, then they become visible for a moment on upper left side - param.width = 0; - param.height = 0; - floatingActionButton.setLayoutParams(param); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/NearbyFABUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/NearbyFABUtils.kt new file mode 100644 index 000000000..61b95a413 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/NearbyFABUtils.kt @@ -0,0 +1,55 @@ +package fr.free.nrw.commons.utils + +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.floatingactionbutton.FloatingActionButton + +object NearbyFABUtils { + + /* + * Add anchors back before making them visible again. + */ + @JvmStatic + fun addAnchorToBigFABs(floatingActionButton: FloatingActionButton, anchorID: Int) { + val params = CoordinatorLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.anchorId = anchorID + params.anchorGravity = Gravity.TOP or Gravity.RIGHT or Gravity.END + floatingActionButton.layoutParams = params + } + + /* + * Add anchors back before making them visible again. Big and small fabs have different anchor + * gravities, therefore there are two methods. + */ + @JvmStatic + fun addAnchorToSmallFABs(floatingActionButton: FloatingActionButton, anchorID: Int) { + val params = CoordinatorLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.anchorId = anchorID + params.anchorGravity = Gravity.CENTER_HORIZONTAL + floatingActionButton.layoutParams = params + } + + /* + * We are not able to hide FABs without removing anchors, this method removes anchors. + */ + @JvmStatic + fun removeAnchorFromFAB(floatingActionButton: FloatingActionButton) { + // get rid of anchors + // Somehow this was the only way https://stackoverflow.com/questions/32732932 + // floatingactionbutton-visible-for-sometime-even-if-visibility-is-set-to-gone + val params = floatingActionButton.layoutParams as CoordinatorLayout.LayoutParams + params.anchorId = View.NO_ID + // If we don't set them to zero, then they become visible for a moment on upper left side + params.width = 0 + params.height = 0 + floatingActionButton.layoutParams = params + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/NetworkUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/NetworkUtils.java deleted file mode 100644 index ce64cb031..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/NetworkUtils.java +++ /dev/null @@ -1,94 +0,0 @@ -package fr.free.nrw.commons.utils; - - -import android.annotation.SuppressLint; -import android.content.Context; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.telephony.TelephonyManager; - -import androidx.annotation.Nullable; - -import fr.free.nrw.commons.utils.model.NetworkConnectionType; - -public class NetworkUtils { - - /** - * https://developer.android.com/training/monitoring-device-state/connectivity-monitoring#java - * Check if internet connection is established. - * - * @param context context passed to this method could be null. - * @return Returns current internet connection status. Returns false if null context was passed. - */ - @SuppressLint("MissingPermission") - public static boolean isInternetConnectionEstablished(@Nullable Context context) { - if (context == null) { - return false; - } - - NetworkInfo activeNetwork = getNetworkInfo(context); - return activeNetwork != null && activeNetwork.isConnectedOrConnecting(); - } - - /** - * Detect network connection type - */ - static NetworkConnectionType getNetworkType(Context context) { - TelephonyManager telephonyManager = (TelephonyManager) context.getApplicationContext().getSystemService(Context.TELEPHONY_SERVICE); - if (telephonyManager == null) { - return NetworkConnectionType.UNKNOWN; - } - - NetworkInfo networkInfo = getNetworkInfo(context); - if (networkInfo == null) { - return NetworkConnectionType.UNKNOWN; - } - - int network = networkInfo.getType(); - if (network == ConnectivityManager.TYPE_WIFI) { - return NetworkConnectionType.WIFI; - } - - // TODO for Android 12+ request permission from user is mandatory - /* - int mobileNetwork = telephonyManager.getNetworkType(); - switch (mobileNetwork) { - case TelephonyManager.NETWORK_TYPE_GPRS: - case TelephonyManager.NETWORK_TYPE_EDGE: - case TelephonyManager.NETWORK_TYPE_CDMA: - case TelephonyManager.NETWORK_TYPE_1xRTT: - return NetworkConnectionType.TWO_G; - case TelephonyManager.NETWORK_TYPE_HSDPA: - case TelephonyManager.NETWORK_TYPE_UMTS: - case TelephonyManager.NETWORK_TYPE_HSUPA: - case TelephonyManager.NETWORK_TYPE_HSPA: - case TelephonyManager.NETWORK_TYPE_EHRPD: - case TelephonyManager.NETWORK_TYPE_EVDO_0: - case TelephonyManager.NETWORK_TYPE_EVDO_A: - case TelephonyManager.NETWORK_TYPE_EVDO_B: - return NetworkConnectionType.THREE_G; - case TelephonyManager.NETWORK_TYPE_LTE: - case TelephonyManager.NETWORK_TYPE_HSPAP: - return NetworkConnectionType.FOUR_G; - default: - return NetworkConnectionType.UNKNOWN; - } - */ - return NetworkConnectionType.UNKNOWN; - } - - /** - * Extracted private method to get nullable network info - */ - @Nullable - private static NetworkInfo getNetworkInfo(Context context) { - ConnectivityManager connectivityManager = - (ConnectivityManager) context.getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); - - if (connectivityManager == null) { - return null; - } - - return connectivityManager.getActiveNetworkInfo(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/NetworkUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/NetworkUtils.kt new file mode 100644 index 000000000..98fde9ef7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/NetworkUtils.kt @@ -0,0 +1,85 @@ +package fr.free.nrw.commons.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkInfo +import android.telephony.TelephonyManager + +import fr.free.nrw.commons.utils.model.NetworkConnectionType + +object NetworkUtils { + + /** + * https://developer.android.com/training/monitoring-device-state/connectivity-monitoring#java + * Check if internet connection is established. + * + * @param context context passed to this method could be null. + * @return Returns current internet connection status. Returns false if null context was passed. + */ + @SuppressLint("MissingPermission") + @JvmStatic + fun isInternetConnectionEstablished(context: Context?): Boolean { + if (context == null) { + return false + } + + val activeNetwork = getNetworkInfo(context) + return activeNetwork != null && activeNetwork.isConnectedOrConnecting + } + + /** + * Detect network connection type + */ + @JvmStatic + fun getNetworkType(context: Context): NetworkConnectionType { + val telephonyManager = context.applicationContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager + ?: return NetworkConnectionType.UNKNOWN + + val networkInfo = getNetworkInfo(context) + ?: return NetworkConnectionType.UNKNOWN + + val network = networkInfo.type + if (network == ConnectivityManager.TYPE_WIFI) { + return NetworkConnectionType.WIFI + } + + // TODO for Android 12+ request permission from user is mandatory + /* + val mobileNetwork = telephonyManager.networkType + return when (mobileNetwork) { + TelephonyManager.NETWORK_TYPE_GPRS, + TelephonyManager.NETWORK_TYPE_EDGE, + TelephonyManager.NETWORK_TYPE_CDMA, + TelephonyManager.NETWORK_TYPE_1xRTT -> NetworkConnectionType.TWO_G + + TelephonyManager.NETWORK_TYPE_HSDPA, + TelephonyManager.NETWORK_TYPE_UMTS, + TelephonyManager.NETWORK_TYPE_HSUPA, + TelephonyManager.NETWORK_TYPE_HSPA, + TelephonyManager.NETWORK_TYPE_EHRPD, + TelephonyManager.NETWORK_TYPE_EVDO_0, + TelephonyManager.NETWORK_TYPE_EVDO_A, + TelephonyManager.NETWORK_TYPE_EVDO_B -> NetworkConnectionType.THREE_G + + TelephonyManager.NETWORK_TYPE_LTE, + TelephonyManager.NETWORK_TYPE_HSPAP -> NetworkConnectionType.FOUR_G + + else -> NetworkConnectionType.UNKNOWN + } + */ + return NetworkConnectionType.UNKNOWN + } + + /** + * Extracted private method to get nullable network info + */ + @JvmStatic + private fun getNetworkInfo(context: Context): NetworkInfo? { + val connectivityManager = + context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return null + + return connectivityManager.activeNetworkInfo + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java deleted file mode 100644 index 9082c1f0f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java +++ /dev/null @@ -1,220 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.Manifest; -import android.Manifest.permission; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.provider.Settings; -import android.widget.Toast; -import androidx.annotation.StringRes; -import androidx.core.content.ContextCompat; -import com.karumi.dexter.Dexter; -import com.karumi.dexter.MultiplePermissionsReport; -import com.karumi.dexter.PermissionToken; -import com.karumi.dexter.listener.PermissionRequest; -import com.karumi.dexter.listener.multi.MultiplePermissionsListener; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.upload.UploadActivity; -import java.util.List; - -public class PermissionUtils { - public static String[] PERMISSIONS_STORAGE = getPermissionsStorage(); - - static String[] getPermissionsStorage() { - if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - return new String[]{ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED, - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.ACCESS_MEDIA_LOCATION }; - } - if(Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU) { - return new String[]{ Manifest.permission.READ_MEDIA_IMAGES, - Manifest. permission.ACCESS_MEDIA_LOCATION }; - } - if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return new String[]{ Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.ACCESS_MEDIA_LOCATION }; - } - return new String[]{ - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE }; - } - - /** - * This method can be used by any activity which requires a permission which has been - * blocked(marked never ask again by the user) It open the app settings from where the user can - * manually give us the required permission. - * - * @param activity The Activity which requires a permission which has been blocked - */ - private static void askUserToManuallyEnablePermissionFromSettings(final Activity activity) { - final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - final Uri uri = Uri.fromParts("package", activity.getPackageName(), null); - intent.setData(uri); - activity.startActivityForResult(intent, - CommonsApplication.OPEN_APPLICATION_DETAIL_SETTINGS); - } - - /** - * Checks whether the app already has a particular permission - * - * @param activity The Activity context to check permissions against - * @param permissions An array of permission strings to check - * @return `true if the app has all the specified permissions, `false` otherwise - */ - public static boolean hasPermission(final Activity activity, final String[] permissions) { - boolean hasPermission = true; - for(final String permission : permissions) { - hasPermission = hasPermission && - ContextCompat.checkSelfPermission(activity, permission) - == PackageManager.PERMISSION_GRANTED; - } - return hasPermission; - } - - public static boolean hasPartialAccess(final Activity activity) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - return ContextCompat.checkSelfPermission(activity, - permission.READ_MEDIA_VISUAL_USER_SELECTED - ) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission( - activity, permission.READ_MEDIA_IMAGES - ) == PackageManager.PERMISSION_DENIED; - } - return false; - } - - /** - * Checks for a particular permission and runs the runnable to perform an action when the - * permission is granted Also, it shows a rationale if needed - *

- * rationaleTitle and rationaleMessage can be invalid @StringRes. If the value is -1 then no - * permission rationale will be displayed and permission would be requested - *

- * Sample usage: - *

- * PermissionUtils.checkPermissionsAndPerformAction(activity, - * Manifest.permission.WRITE_EXTERNAL_STORAGE, () -> initiateCameraUpload(activity), - * R.string.storage_permission_title, R.string.write_storage_permission_rationale); - *

- * If you don't want the permission rationale to be shown then use: - *

- * PermissionUtils.checkPermissionsAndPerformAction(activity, - * Manifest.permission.WRITE_EXTERNAL_STORAGE, () -> initiateCameraUpload(activity), - 1, -1); - * - * @param activity activity requesting permissions - * @param permissions the permissions array being requests - * @param onPermissionGranted the runnable to be executed when the permission is granted - * @param rationaleTitle rationale title to be displayed when permission was denied. It can - * be an invalid @StringRes - * @param rationaleMessage rationale message to be displayed when permission was denied. It - * can be an invalid @StringRes - */ - public static void checkPermissionsAndPerformAction( - final Activity activity, - final Runnable onPermissionGranted, - final @StringRes int rationaleTitle, - final @StringRes int rationaleMessage, - final String... permissions - ) { - if (hasPartialAccess(activity)) { - onPermissionGranted.run(); - return; - } - checkPermissionsAndPerformAction(activity, onPermissionGranted, null, - rationaleTitle, rationaleMessage, permissions); - } - - /** - * Checks for a particular permission and runs the corresponding runnables to perform an action - * when the permission is granted/denied Also, it shows a rationale if needed - *

- * Sample usage: - *

- * PermissionUtils.checkPermissionsAndPerformAction(activity, - * Manifest.permission.WRITE_EXTERNAL_STORAGE, () -> initiateCameraUpload(activity), () -> - * showMessage(), R.string.storage_permission_title, - * R.string.write_storage_permission_rationale); - * - * @param activity activity requesting permissions - * @param permissions the permissions array being requested - * @param onPermissionGranted the runnable to be executed when the permission is granted - * @param onPermissionDenied the runnable to be executed when the permission is denied(but not - * permanently) - * @param rationaleTitle rationale title to be displayed when permission was denied - * @param rationaleMessage rationale message to be displayed when permission was denied - */ - public static void checkPermissionsAndPerformAction( - final Activity activity, - final Runnable onPermissionGranted, - final Runnable onPermissionDenied, - final @StringRes int rationaleTitle, - final @StringRes int rationaleMessage, - final String... permissions - ) { - Dexter.withActivity(activity) - .withPermissions(permissions) - .withListener(new MultiplePermissionsListener() { - @Override - public void onPermissionsChecked(final MultiplePermissionsReport report) { - if (report.areAllPermissionsGranted() || hasPartialAccess(activity)) { - onPermissionGranted.run(); - return; - } - if (report.isAnyPermissionPermanentlyDenied()) { - // permission is denied permanently, we will show user a dialog message. - DialogUtil.showAlertDialog( - activity, activity.getString(rationaleTitle), - activity.getString(rationaleMessage), - activity.getString(R.string.navigation_item_settings), - null, () -> { - askUserToManuallyEnablePermissionFromSettings(activity); - if (activity instanceof UploadActivity) { - ((UploadActivity) activity).setShowPermissionsDialog(true); - } - }, null, null, - !(activity instanceof UploadActivity)); - } else { - if (null != onPermissionDenied) { - onPermissionDenied.run(); - } - } - } - - @Override - public void onPermissionRationaleShouldBeShown( - final List permissions, - final PermissionToken token - ) { - if (rationaleTitle == -1 && rationaleMessage == -1) { - token.continuePermissionRequest(); - return; - } - DialogUtil.showAlertDialog( - activity, activity.getString(rationaleTitle), - activity.getString(rationaleMessage), - activity.getString(android.R.string.ok), - activity.getString(android.R.string.cancel), - () -> { - if (activity instanceof UploadActivity) { - ((UploadActivity) activity).setShowPermissionsDialog(true); - } - token.continuePermissionRequest(); - }, - () -> { - Toast.makeText(activity.getApplicationContext(), - R.string.permissions_are_required_for_functionality, - Toast.LENGTH_LONG - ).show(); - token.cancelPermissionRequest(); - if (activity instanceof UploadActivity) { - activity.finish(); - } - }, null, false - ); - } - }).onSameThread().check(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.kt new file mode 100644 index 000000000..f5a98dc5d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.kt @@ -0,0 +1,231 @@ +package fr.free.nrw.commons.utils + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.core.content.ContextCompat +import com.karumi.dexter.Dexter +import com.karumi.dexter.MultiplePermissionsReport +import com.karumi.dexter.PermissionToken +import com.karumi.dexter.listener.PermissionRequest +import com.karumi.dexter.listener.multi.MultiplePermissionsListener +import fr.free.nrw.commons.R +import fr.free.nrw.commons.upload.UploadActivity + + +object PermissionUtils { + + @JvmStatic + val PERMISSIONS_STORAGE: Array = getPermissionsStorage() + + @JvmStatic + private fun getPermissionsStorage(): Array { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> arrayOf( + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED, + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.ACCESS_MEDIA_LOCATION + ) + Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU -> arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.ACCESS_MEDIA_LOCATION + ) + Build.VERSION.SDK_INT > Build.VERSION_CODES.Q -> arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.ACCESS_MEDIA_LOCATION + ) + Build.VERSION.SDK_INT == Build.VERSION_CODES.Q -> arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.ACCESS_MEDIA_LOCATION + ) + else -> arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + } + } + + /** + * This method can be used by any activity which requires a permission which has been + * blocked(marked never ask again by the user) It open the app settings from where the user can + * manually give us the required permission. + * + * @param activity The Activity which requires a permission which has been blocked + */ + @JvmStatic + private fun askUserToManuallyEnablePermissionFromSettings(activity: Activity) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", activity.packageName, null) + } + activity.startActivity(intent) + } + + /** + * Checks whether the app already has a particular permission + * + * @param activity The Activity context to check permissions against + * @param permissions An array of permission strings to check + * @return `true if the app has all the specified permissions, `false` otherwise + */ + @JvmStatic + fun hasPermission(activity: Activity, permissions: Array): Boolean { + return permissions.all { permission -> + ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED + } + } + + /** + * Check if the app has partial access permissions. + */ + @JvmStatic + fun hasPartialAccess(activity: Activity): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ContextCompat.checkSelfPermission( + activity, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + activity, Manifest.permission.READ_MEDIA_IMAGES + ) == PackageManager.PERMISSION_DENIED + } else false + } + + /** + * Checks for a particular permission and runs the runnable to perform an action when the + * permission is granted Also, it shows a rationale if needed + *

+ * rationaleTitle and rationaleMessage can be invalid @StringRes. If the value is -1 then no + * permission rationale will be displayed and permission would be requested + *

+ * Sample usage: + *

+ * PermissionUtils.checkPermissionsAndPerformAction(activity, + * Manifest.permission.WRITE_EXTERNAL_STORAGE, () -> initiateCameraUpload(activity), + * R.string.storage_permission_title, R.string.write_storage_permission_rationale); + *

+ * If you don't want the permission rationale to be shown then use: + *

+ * PermissionUtils.checkPermissionsAndPerformAction(activity, + * Manifest.permission.WRITE_EXTERNAL_STORAGE, () -> initiateCameraUpload(activity), - 1, -1); + * + * @param activity activity requesting permissions + * @param permissions the permissions array being requests + * @param onPermissionGranted the runnable to be executed when the permission is granted + * @param rationaleTitle rationale title to be displayed when permission was denied. It can + * be an invalid @StringRes + * @param rationaleMessage rationale message to be displayed when permission was denied. It + * can be an invalid @StringRes + */ + @JvmStatic + fun checkPermissionsAndPerformAction( + activity: Activity, + onPermissionGranted: Runnable, + rationaleTitle: Int, + rationaleMessage: Int, + vararg permissions: String + ) { + if (hasPartialAccess(activity)) { + Thread(onPermissionGranted).start() + return + } + checkPermissionsAndPerformAction( + activity, onPermissionGranted, null, rationaleTitle, rationaleMessage, *permissions + ) + } + + /** + * Checks for a particular permission and runs the corresponding runnables to perform an action + * when the permission is granted/denied Also, it shows a rationale if needed + *

+ * Sample usage: + *

+ * PermissionUtils.checkPermissionsAndPerformAction(activity, + * Manifest.permission.WRITE_EXTERNAL_STORAGE, () -> initiateCameraUpload(activity), () -> + * showMessage(), R.string.storage_permission_title, + * R.string.write_storage_permission_rationale); + * + * @param activity activity requesting permissions + * @param permissions the permissions array being requested + * @param onPermissionGranted the runnable to be executed when the permission is granted + * @param onPermissionDenied the runnable to be executed when the permission is denied(but not + * permanently) + * @param rationaleTitle rationale title to be displayed when permission was denied + * @param rationaleMessage rationale message to be displayed when permission was denied + */ + @JvmStatic + fun checkPermissionsAndPerformAction( + activity: Activity, + onPermissionGranted: Runnable, + onPermissionDenied: Runnable? = null, + rationaleTitle: Int, + rationaleMessage: Int, + vararg permissions: String + ) { + Dexter.withActivity(activity) + .withPermissions(*permissions) + .withListener(object : MultiplePermissionsListener { + override fun onPermissionsChecked(report: MultiplePermissionsReport) { + when { + report.areAllPermissionsGranted() || hasPartialAccess(activity) -> + Thread(onPermissionGranted).start() + report.isAnyPermissionPermanentlyDenied -> { + DialogUtil.showAlertDialog( + activity, + activity.getString(rationaleTitle), + activity.getString(rationaleMessage), + activity.getString(R.string.navigation_item_settings), + null, + { + askUserToManuallyEnablePermissionFromSettings(activity) + if (activity is UploadActivity) { + activity.isShowPermissionsDialog = true + } + }, + null, null + ) + } + else -> Thread(onPermissionDenied).start() + } + } + + override fun onPermissionRationaleShouldBeShown( + permissions: List, token: PermissionToken + ) { + if (rationaleTitle == -1 && rationaleMessage == -1) { + token.continuePermissionRequest() + return + } + DialogUtil.showAlertDialog( + activity, + activity.getString(rationaleTitle), + activity.getString(rationaleMessage), + activity.getString(android.R.string.ok), + activity.getString(android.R.string.cancel), + { + if (activity is UploadActivity) { + activity.setShowPermissionsDialog(true) + } + token.continuePermissionRequest() + }, + { + Toast.makeText( + activity.applicationContext, + R.string.permissions_are_required_for_functionality, + Toast.LENGTH_LONG + ).show() + token.cancelPermissionRequest() + if (activity is UploadActivity) { + activity.finish() + } + }, + null + ) + } + }).onSameThread().check() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/PlaceUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/PlaceUtils.java deleted file mode 100644 index f1022a041..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/PlaceUtils.java +++ /dev/null @@ -1,55 +0,0 @@ -package fr.free.nrw.commons.utils; - -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.nearby.Sitelinks; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import fr.free.nrw.commons.location.LatLng; - -public class PlaceUtils { - - public static LatLng latLngFromPointString(String pointString) { - double latitude; - double longitude; - Matcher matcher = Pattern.compile("Point\\(([^ ]+) ([^ ]+)\\)").matcher(pointString); - if (!matcher.find()) { - return null; - } - try { - longitude = Double.parseDouble(matcher.group(1)); - latitude = Double.parseDouble(matcher.group(2)); - } catch (NumberFormatException e) { - return null; - } - - return new LatLng(latitude, longitude, 0); - } - - /** - * Turns a Media list to a Place list by creating a new list in Place type - * @param mediaList - * @return - */ - public static List mediaToExplorePlace( List mediaList) { - List explorePlaceList = new ArrayList<>(); - for (Media media :mediaList) { - explorePlaceList.add(new Place(media.getFilename(), - media.getFallbackDescription(), - media.getCoordinates(), - media.getCategories().toString(), - new Sitelinks.Builder() - .setCommonsLink(media.getPageTitle().getCanonicalUri()) - .setWikipediaLink("") // we don't necessarily have them, can be fetched later - .setWikidataLink("") // we don't necessarily have them, can be fetched later - .build(), - media.getImageUrl(), - media.getThumbUrl(), - "")); - } - return explorePlaceList; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/PlaceUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/PlaceUtils.kt new file mode 100644 index 000000000..907420f21 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/PlaceUtils.kt @@ -0,0 +1,50 @@ +package fr.free.nrw.commons.utils + +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.Sitelinks + +object PlaceUtils { + + @JvmStatic + fun latLngFromPointString(pointString: String): LatLng? { + val matcher = Regex("Point\\(([^ ]+) ([^ ]+)\\)").find(pointString) ?: return null + return try { + val longitude = matcher.groupValues[1].toDouble() + val latitude = matcher.groupValues[2].toDouble() + LatLng(latitude, longitude, 0.0F) + } catch (e: NumberFormatException) { + null + } + } + + /** + * Turns a Media list to a Place list by creating a new list in Place type + * @param mediaList + * @return + */ + @JvmStatic + fun mediaToExplorePlace(mediaList: List): List { + val explorePlaceList = mutableListOf() + for (media in mediaList) { + explorePlaceList.add( + Place( + media.filename, + media.fallbackDescription, + media.coordinates, + media.categories.toString(), + Sitelinks.Builder() + .setCommonsLink(media.pageTitle.canonicalUri) + .setWikipediaLink("") // we don't necessarily have them, can be fetched later + .setWikidataLink("") // we don't necessarily have them, can be fetched later + .build(), + media.imageUrl, + media.thumbUrl, + "" + ) + ) + } + return explorePlaceList + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/StringSortingUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/StringSortingUtils.java deleted file mode 100644 index 314467972..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/StringSortingUtils.java +++ /dev/null @@ -1,90 +0,0 @@ -package fr.free.nrw.commons.utils; - -import fr.free.nrw.commons.category.CategoryItem; -import java.util.Comparator; - -public class StringSortingUtils { - - private StringSortingUtils() { - //no-op - } - - /** - * Returns Comparator for sorting strings by their similarity to the filter. - * By using this Comparator we get results - * from the highest to the lowest similarity with the filter. - * - * @param filter String to compare similarity with - * @return Comparator with string similarity - */ - public static Comparator sortBySimilarity(final String filter) { - return (firstItem, secondItem) -> { - double firstItemSimilarity = calculateSimilarity(firstItem.getName(), filter); - double secondItemSimilarity = calculateSimilarity(secondItem.getName(), filter); - return (int) Math.signum(secondItemSimilarity - firstItemSimilarity); - }; - } - - - /** - * Determines String similarity between str1 and str2 on scale from 0.0 to 1.0 - * @param str1 String 1 - * @param str2 String 2 - * @return Double between 0.0 and 1.0 that reflects string similarity - */ - private static double calculateSimilarity(String str1, String str2) { - int longerLength = Math.max(str1.length(), str2.length()); - - if (longerLength == 0) return 1.0; - - int distanceBetweenStrings = levenshteinDistance(str1, str2); - return (longerLength - distanceBetweenStrings) / (double) longerLength; - } - - /** - * Levershtein distance algorithm - * https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Java - * - * @param str1 String 1 - * @param str2 String 2 - * @return Number of characters the strings differ by - */ - private static int levenshteinDistance(String str1, String str2) { - if (str1.equals(str2)) return 0; - if (str1.length() == 0) return str2.length(); - if (str2.length() == 0) return str1.length(); - - int[] cost = new int[str1.length() + 1]; - int[] newcost = new int[str1.length() + 1]; - - // initial cost of skipping prefix in str1 - for (int i = 0; i < cost.length; i++) cost[i] = i; - - // transformation cost for each letter in str2 - for (int j = 1; j <= str2.length(); j++) { - // initial cost of skipping prefix in String str2 - newcost[0] = j; - - // transformation cost for each letter in str1 - for(int i = 1; i < cost.length; i++) { - // matching current letters in both strings - int match = (str1.charAt(i - 1) == str2.charAt(j - 1)) ? 0 : 1; - - // computing cost for each transformation - int cost_replace = cost[i - 1] + match; - int cost_insert = cost[i] + 1; - int cost_delete = newcost[i - 1] + 1; - - // keep minimum cost - newcost[i] = Math.min(Math.min(cost_insert, cost_delete), cost_replace); - } - - int[] tmp = cost; - cost = newcost; - newcost = tmp; - } - - // the distance is the cost for transforming all letters in both strings - return cost[str1.length()]; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/StringSortingUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/StringSortingUtils.kt new file mode 100644 index 000000000..d9f813ae0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/StringSortingUtils.kt @@ -0,0 +1,86 @@ +package fr.free.nrw.commons.utils + +import fr.free.nrw.commons.category.CategoryItem +import java.lang.Math.signum +import java.util.Comparator + + +object StringSortingUtils { + + /** + * Returns Comparator for sorting strings by their similarity to the filter. + * By using this Comparator we get results + * from the highest to the lowest similarity with the filter. + * + * @param filter String to compare similarity with + * @return Comparator with string similarity + */ + @JvmStatic + fun sortBySimilarity(filter: String): Comparator { + return Comparator { firstItem, secondItem -> + val firstItemSimilarity = calculateSimilarity(firstItem.name, filter) + val secondItemSimilarity = calculateSimilarity(secondItem.name, filter) + signum(secondItemSimilarity - firstItemSimilarity).toInt() + } + } + + /** + * Determines String similarity between str1 and str2 on scale from 0.0 to 1.0 + * @param str1 String 1 + * @param str2 String 2 + * @return Double between 0.0 and 1.0 that reflects string similarity + */ + private fun calculateSimilarity(str1: String, str2: String): Double { + val longerLength = maxOf(str1.length, str2.length) + + if (longerLength == 0) return 1.0 + + val distanceBetweenStrings = levenshteinDistance(str1, str2) + return (longerLength - distanceBetweenStrings) / longerLength.toDouble() + } + + /** + * Levenshtein distance algorithm + * https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Java + * + * @param str1 String 1 + * @param str2 String 2 + * @return Number of characters the strings differ by + */ + private fun levenshteinDistance(str1: String, str2: String): Int { + if (str1 == str2) return 0 + if (str1.isEmpty()) return str2.length + if (str2.isEmpty()) return str1.length + + var cost = IntArray(str1.length + 1) { it } + var newCost = IntArray(str1.length + 1) + + // transformation cost for each letter in str2 + for (j in 1..str2.length) { + // initial cost of skipping prefix in String str2 + newCost[0] = j + + // transformation cost for each letter in str1 + for (i in 1..str1.length) { + // matching current letters in both strings + val match = if (str1[i - 1] == str2[j - 1]) 0 else 1 + + // computing cost for each transformation + val costReplace = cost[i - 1] + match + val costInsert = cost[i] + 1 + val costDelete = newCost[i - 1] + 1 + + // keep minimum cost + newCost[i] = minOf(costInsert, costDelete, costReplace) + } + + // swap cost arrays + val tmp = cost + cost = newCost + newCost = tmp + } + + // the distance is the cost for transforming all letters in both strings + return cost[str1.length] + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.java b/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.java deleted file mode 100644 index a5bb6038e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.java +++ /dev/null @@ -1,38 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.os.Build; -import android.text.Html; -import android.text.Spanned; -import android.text.SpannedString; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public final class StringUtil { - - /** - * @param source String that may contain HTML tags. - * @return returned Spanned string that may contain spans parsed from the HTML source. - */ - @NonNull public static Spanned fromHtml(@Nullable String source) { - if (source == null) { - return new SpannedString(""); - } - if (!source.contains("<") && !source.contains("&")) { - // If the string doesn't contain any hints of HTML entities, then skip the expensive - // processing that fromHtml() performs. - return new SpannedString(source); - } - source = source.replaceAll("‎", "\u200E") - .replaceAll("‏", "\u200F") - .replaceAll("&", "&"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - return Html.fromHtml(source, Html.FROM_HTML_MODE_LEGACY); - } else { - //noinspection deprecation - return Html.fromHtml(source); - } - } - - private StringUtil() { - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.kt new file mode 100644 index 000000000..b3c58d8b2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/StringUtil.kt @@ -0,0 +1,37 @@ +package fr.free.nrw.commons.utils + +import android.os.Build +import android.text.Html +import android.text.Spanned +import android.text.SpannedString + +object StringUtil { + + /** + * @param source String that may contain HTML tags. + * @return returned Spanned string that may contain spans parsed from the HTML source. + */ + @JvmStatic + fun fromHtml(source: String?): Spanned { + if (source == null) { + return SpannedString("") + } + if (!source.contains("<") && !source.contains("&")) { + // If the string doesn't contain any hints of HTML entities, then skip the expensive + // processing that fromHtml() performs. + return SpannedString(source) + } + val processedSource = source + .replace("‎", "\u200E") + .replace("‏", "\u200F") + .replace("&", "&") + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(processedSource, Html.FROM_HTML_MODE_LEGACY) + } else { + //noinspection deprecation + @Suppress("DEPRECATION") + Html.fromHtml(processedSource) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/SwipableCardView.java b/app/src/main/java/fr/free/nrw/commons/utils/SwipableCardView.java deleted file mode 100644 index 7ea7ef467..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/SwipableCardView.java +++ /dev/null @@ -1,74 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.content.Context; -import android.content.res.Resources; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.cardview.widget.CardView; - -import timber.log.Timber; - -/** - * A card view which informs onSwipe events to its child - */ -public abstract class SwipableCardView extends CardView { - float x1, x2; - private static final float MINIMUM_THRESHOLD_FOR_SWIPE = 100; - - public SwipableCardView(@NonNull Context context) { - super(context); - interceptOnTouchListener(); - } - - public SwipableCardView(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - interceptOnTouchListener(); - } - - public SwipableCardView(@NonNull Context context, @Nullable AttributeSet attrs, - int defStyleAttr) { - super(context, attrs, defStyleAttr); - interceptOnTouchListener(); - } - - private void interceptOnTouchListener() { - this.setOnTouchListener((v, event) -> { - boolean isSwipe = false; - float deltaX = 0.0f; - Timber.e(event.getAction() + ""); - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - x1 = event.getX(); - break; - case MotionEvent.ACTION_UP: - x2 = event.getX(); - deltaX = x2 - x1; - if (deltaX < 0) { - //Right to left swipe - isSwipe = true; - } else if (deltaX > 0) { - //Left to right swipe - isSwipe = true; - } - break; - } - if (isSwipe && (pixelToDp(Math.abs(deltaX)) > MINIMUM_THRESHOLD_FOR_SWIPE)) { - return onSwipe(v); - } - return false; - }); - } - - /** - * abstract function which informs swipe events to those who have inherited from it - */ - public abstract boolean onSwipe(View view); - - private float pixelToDp(float pixels) { - return (pixels / Resources.getSystem().getDisplayMetrics().density); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/SwipableCardView.kt b/app/src/main/java/fr/free/nrw/commons/utils/SwipableCardView.kt new file mode 100644 index 000000000..5a8261c24 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/SwipableCardView.kt @@ -0,0 +1,64 @@ +package fr.free.nrw.commons.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View + +import androidx.cardview.widget.CardView + +import timber.log.Timber +import kotlin.math.abs + +/** + * A card view which informs onSwipe events to its child + */ +abstract class SwipableCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : CardView(context, attrs, defStyleAttr) { + + private var x1 = 0f + private var x2 = 0f + private val MINIMUM_THRESHOLD_FOR_SWIPE = 100f + + init { + interceptOnTouchListener() + } + + @SuppressLint("ClickableViewAccessibility") + private fun interceptOnTouchListener() { + this.setOnTouchListener { v, event -> + var isSwipe = false + var deltaX = 0f + Timber.e(event.action.toString()) + when (event.action) { + MotionEvent.ACTION_DOWN -> { + x1 = event.x + } + MotionEvent.ACTION_UP -> { + x2 = event.x + deltaX = x2 - x1 + isSwipe = deltaX != 0f + } + } + if (isSwipe && pixelToDp(abs(deltaX)) > MINIMUM_THRESHOLD_FOR_SWIPE) { + onSwipe(v) + return@setOnTouchListener true + } + false + } + } + + /** + * abstract function which informs swipe events to those who have inherited from it + */ + abstract fun onSwipe(view: View): Boolean + + private fun pixelToDp(pixels: Float): Float { + return pixels / Resources.getSystem().displayMetrics.density + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.java deleted file mode 100644 index aa60a7aa8..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.java +++ /dev/null @@ -1,49 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.content.Context; -import android.content.res.Configuration; - -import javax.inject.Inject; -import javax.inject.Named; - -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.settings.Prefs; - -public class SystemThemeUtils { - - private Context context; - private JsonKvStore applicationKvStore; - - public static final String THEME_MODE_DEFAULT = "0"; - public static final String THEME_MODE_DARK = "1"; - public static final String THEME_MODE_LIGHT = "2"; - - @Inject - public SystemThemeUtils(Context context, @Named("default_preferences") JsonKvStore applicationKvStore) { - this.context = context; - this.applicationKvStore = applicationKvStore; - } - - // Return true is system wide dark theme is enabled else false - public boolean getSystemDefaultThemeBool(String theme) { - if (theme.equals(THEME_MODE_DARK)) { - return true; - } else if (theme.equals(THEME_MODE_DEFAULT)) { - return getSystemDefaultThemeBool(getSystemDefaultTheme()); - } - return false; - } - - // Returns the default system wide theme - public String getSystemDefaultTheme() { - return (context.getResources().getConfiguration().uiMode & - Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES ? THEME_MODE_DARK : THEME_MODE_LIGHT; - } - - // Returns true if the device is in night mode or false otherwise - public boolean isDeviceInNightMode() { - return getSystemDefaultThemeBool( - applicationKvStore.getString(Prefs.KEY_THEME_VALUE, getSystemDefaultTheme())); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.kt new file mode 100644 index 000000000..87a710424 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.kt @@ -0,0 +1,52 @@ +package fr.free.nrw.commons.utils + +import android.content.Context +import android.content.res.Configuration + +import javax.inject.Inject +import javax.inject.Named + +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.settings.Prefs + + +class SystemThemeUtils @Inject constructor( + private val context: Context, + @Named("default_preferences") private val applicationKvStore: JsonKvStore +) { + + companion object { + const val THEME_MODE_DEFAULT = "0" + const val THEME_MODE_DARK = "1" + const val THEME_MODE_LIGHT = "2" + } + + // Return true if system wide dark theme is enabled else false + private fun getSystemDefaultThemeBool(theme: String): Boolean { + return when (theme) { + THEME_MODE_DARK -> true + THEME_MODE_DEFAULT -> getSystemDefaultThemeBool(getSystemDefaultTheme()) + else -> false + } + } + + // Returns the default system wide theme + private fun getSystemDefaultTheme(): String { + return if ( + ( + context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) + == Configuration.UI_MODE_NIGHT_YES + ) { + THEME_MODE_DARK + } else { + THEME_MODE_LIGHT + } + } + + // Returns true if the device is in night mode or false otherwise + fun isDeviceInNightMode(): Boolean { + return getSystemDefaultThemeBool( + applicationKvStore.getString(Prefs.KEY_THEME_VALUE, getSystemDefaultTheme())!! + ) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/TimeProvider.kt b/app/src/main/java/fr/free/nrw/commons/utils/TimeProvider.kt new file mode 100644 index 000000000..e09b8fb45 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/TimeProvider.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.utils + +fun interface TimeProvider { + fun currentTimeMillis(): Long +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/utils/UiUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/UiUtils.java deleted file mode 100644 index acb6afbaa..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/UiUtils.java +++ /dev/null @@ -1,39 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.util.DisplayMetrics; - -import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; - -import java.util.ArrayList; -import java.util.List; - -public class UiUtils { - - /** - * Draws a vectorial image onto a bitmap. - * @param vectorDrawable vectorial image - * @return bitmap representation of the vectorial image - */ - public static Bitmap getBitmap(VectorDrawableCompat vectorDrawable) { - Bitmap bitmap = Bitmap.createBitmap(vectorDrawable.getIntrinsicWidth(), - vectorDrawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - vectorDrawable.draw(canvas); - return bitmap; - } - - /** - * Converts dp unit to equivalent pixels. - * @param dp density independent pixels - * @param context Context to access display metrics - * @return px equivalent to dp value - */ - public static float convertDpToPixel(float dp, Context context) { - DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - return dp * ((float) metrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/UiUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/UiUtils.kt new file mode 100644 index 000000000..9ff069ebc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/UiUtils.kt @@ -0,0 +1,41 @@ +package fr.free.nrw.commons.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.util.DisplayMetrics +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat + + +object UiUtils { + + /** + * Draws a vectorial image onto a bitmap. + * @param vectorDrawable vectorial image + * @return bitmap representation of the vectorial image + */ + @JvmStatic + fun getBitmap(vectorDrawable: VectorDrawableCompat): Bitmap { + val bitmap = Bitmap.createBitmap( + vectorDrawable.intrinsicWidth, + vectorDrawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) + vectorDrawable.draw(canvas) + return bitmap + } + + /** + * Converts dp unit to equivalent pixels. + * @param dp density independent pixels + * @param context Context to access display metrics + * @return px equivalent to dp value + */ + @JvmStatic + fun convertDpToPixel(dp: Float, context: Context): Float { + val metrics = context.resources.displayMetrics + return dp * (metrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT.toFloat()) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.java b/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.java deleted file mode 100644 index 1272dc4f1..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.java +++ /dev/null @@ -1,143 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.app.Activity; -import android.content.Context; -import android.graphics.Color; -import android.view.Display; -import android.view.View; -import android.view.inputmethod.InputMethodManager; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.StringRes; - -import androidx.core.content.ContextCompat; -import com.google.android.material.snackbar.Snackbar; - -import fr.free.nrw.commons.R; -import timber.log.Timber; - -public class ViewUtil { - /** - * Utility function to show short snack bar - * @param view - * @param messageResourceId - */ - public static void showShortSnackbar(View view, int messageResourceId) { - if (view.getContext() == null) { - return; - } - - ExecutorUtils.uiExecutor().execute(() -> { - try { - Snackbar.make(view, messageResourceId, Snackbar.LENGTH_SHORT).show(); - }catch (IllegalStateException e){ - Timber.e(e.getMessage()); - } - }); - } - public static void showLongSnackbar(View view, String text) { - if(view.getContext() == null) { - return; - } - - ExecutorUtils.uiExecutor().execute(()-> { - try { - Snackbar snackbar = Snackbar.make(view, text, Snackbar.LENGTH_SHORT); - - View snack_view = snackbar.getView(); - TextView snack_text = snack_view.findViewById(R.id.snackbar_text); - - snack_view.setBackgroundColor(Color.LTGRAY); - snack_text.setTextColor(ContextCompat.getColor(view.getContext(), R.color.primaryColor)); - snackbar.setActionTextColor(Color.RED); - - snackbar.setAction("Dismiss", new View.OnClickListener() { - @Override - public void onClick(View v) { - // Handle the action click - snackbar.dismiss(); - } - }); - - snackbar.show(); - - }catch (IllegalStateException e) { - Timber.e(e.getMessage()); - } - }); - } - - public static void showLongToast(Context context, String text) { - if (context == null) { - return; - } - - ExecutorUtils.uiExecutor().execute(() -> Toast.makeText(context, text, Toast.LENGTH_LONG).show()); - } - - public static void showLongToast(Context context, @StringRes int stringResourceId) { - if (context == null) { - return; - } - - ExecutorUtils.uiExecutor().execute(() -> Toast.makeText(context, context.getString(stringResourceId), Toast.LENGTH_LONG).show()); - } - - public static void showShortToast(Context context, String text) { - if (context == null) { - return; - } - - ExecutorUtils.uiExecutor().execute(() -> Toast.makeText(context, text, Toast.LENGTH_SHORT).show()); - } - - public static void showShortToast(Context context, @StringRes int stringResourceId) { - if (context == null) { - return; - } - - ExecutorUtils.uiExecutor().execute(() -> Toast.makeText(context, context.getString(stringResourceId), Toast.LENGTH_SHORT).show()); - } - - public static boolean isPortrait(Context context) { - Display orientation = ((Activity)context).getWindowManager().getDefaultDisplay(); - if (orientation.getWidth() < orientation.getHeight()){ - return true; - } else { - return false; - } - } - - public static void hideKeyboard(View view){ - if (view != null) { - InputMethodManager manager = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - view.clearFocus(); - if (manager != null) { - manager.hideSoftInputFromWindow(view.getWindowToken(), 0); - } - } - } - - /** - * A snack bar which has an action button which on click dismisses the snackbar and invokes the - * listener passed - */ - public static void showDismissibleSnackBar(View view, - int messageResourceId, - int actionButtonResourceId, - View.OnClickListener onClickListener) { - if (view.getContext() == null) { - return; - } - ExecutorUtils.uiExecutor().execute(() -> { - Snackbar snackbar = Snackbar.make(view, view.getContext().getString(messageResourceId), - Snackbar.LENGTH_INDEFINITE); - snackbar.setAction(view.getContext().getString(actionButtonResourceId), v -> { - snackbar.dismiss(); - onClickListener.onClick(v); - }); - snackbar.show(); - }); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.kt b/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.kt new file mode 100644 index 000000000..64970ecf6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.kt @@ -0,0 +1,151 @@ +package fr.free.nrw.commons.utils + +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.view.Display +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.TextView +import android.widget.Toast + +import androidx.annotation.StringRes + +import androidx.core.content.ContextCompat +import com.google.android.material.snackbar.Snackbar + +import fr.free.nrw.commons.R +import timber.log.Timber + + +object ViewUtil { + + /** + * Utility function to show short snack bar + * @param view + * @param messageResourceId + */ + @JvmStatic + fun showShortSnackbar(view: View, messageResourceId: Int) { + if (view.context == null) { + return + } + + ExecutorUtils.uiExecutor().execute { + try { + Snackbar.make(view, messageResourceId, Snackbar.LENGTH_SHORT).show() + } catch (e: IllegalStateException) { + Timber.e(e.message) + } + } + } + + @JvmStatic + fun showLongSnackbar(view: View, text: String) { + if (view.context == null) { + return + } + + ExecutorUtils.uiExecutor().execute { + try { + val snackbar = Snackbar.make(view, text, Snackbar.LENGTH_SHORT) + val snackView = snackbar.view + val snackText: TextView = snackView.findViewById(R.id.snackbar_text) + + snackView.setBackgroundColor(Color.LTGRAY) + snackText.setTextColor(ContextCompat.getColor(view.context, R.color.primaryColor)) + snackbar.setActionTextColor(Color.RED) + + snackbar.setAction("Dismiss") { snackbar.dismiss() } + snackbar.show() + + } catch (e: IllegalStateException) { + Timber.e(e.message) + } + } + } + + @JvmStatic + fun showLongToast(context: Context, text: String) { + if (context == null) { + return + } + + ExecutorUtils.uiExecutor().execute { + Toast.makeText(context, text, Toast.LENGTH_LONG).show() + } + } + + @JvmStatic + fun showLongToast(context: Context, @StringRes stringResourceId: Int) { + if (context == null) { + return + } + + ExecutorUtils.uiExecutor().execute { + Toast.makeText(context, context.getString(stringResourceId), Toast.LENGTH_LONG).show() + } + } + + @JvmStatic + fun showShortToast(context: Context, text: String) { + if (context == null) { + return + } + + ExecutorUtils.uiExecutor().execute { + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + } + } + + @JvmStatic + fun showShortToast(context: Context?, @StringRes stringResourceId: Int) { + if (context == null) { + return + } + + ExecutorUtils.uiExecutor().execute { + Toast.makeText(context, context.getString(stringResourceId), Toast.LENGTH_SHORT).show() + } + } + + @JvmStatic + fun isPortrait(context: Context): Boolean { + val orientation = (context as Activity).windowManager.defaultDisplay + return orientation.width < orientation.height + } + + @JvmStatic + fun hideKeyboard(view: View?) { + view?.let { + val manager = it.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + it.clearFocus() + manager?.hideSoftInputFromWindow(it.windowToken, 0) + } + } + + /** + * A snack bar which has an action button which on click dismisses the snackbar and invokes the + * listener passed + */ + @JvmStatic + fun showDismissibleSnackBar( + view: View, + messageResourceId: Int, + actionButtonResourceId: Int, + onClickListener: View.OnClickListener + ) { + if (view.context == null) { + return + } + + ExecutorUtils.uiExecutor().execute { + val snackbar = Snackbar.make(view, view.context.getString(messageResourceId), Snackbar.LENGTH_INDEFINITE) + snackbar.setAction(view.context.getString(actionButtonResourceId)) { + snackbar.dismiss() + onClickListener.onClick(it) + } + snackbar.show() + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ViewUtilWrapper.java b/app/src/main/java/fr/free/nrw/commons/utils/ViewUtilWrapper.java deleted file mode 100644 index 2721ef98d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/utils/ViewUtilWrapper.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.free.nrw.commons.utils; - -import android.content.Context; - -import javax.inject.Inject; -import javax.inject.Singleton; - -@Singleton -public class ViewUtilWrapper { - - @Inject - public ViewUtilWrapper() { - - } - - public void showShortToast(Context context, String text) { - ViewUtil.showShortToast(context, text); - } - - public void showLongToast(Context context, String text) { - ViewUtil.showLongToast(context, text); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ViewUtilWrapper.kt b/app/src/main/java/fr/free/nrw/commons/utils/ViewUtilWrapper.kt new file mode 100644 index 000000000..b5ead3041 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/ViewUtilWrapper.kt @@ -0,0 +1,17 @@ +package fr.free.nrw.commons.utils + +import android.content.Context +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ViewUtilWrapper @Inject constructor() { + + fun showShortToast(context: Context, text: String) { + ViewUtil.showShortToast(context, text) + } + + fun showLongToast(context: Context, text: String) { + ViewUtil.showLongToast(context, text) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/widget/HeightLimitedRecyclerView.java b/app/src/main/java/fr/free/nrw/commons/widget/HeightLimitedRecyclerView.java deleted file mode 100644 index 5c6dde5fd..000000000 --- a/app/src/main/java/fr/free/nrw/commons/widget/HeightLimitedRecyclerView.java +++ /dev/null @@ -1,48 +0,0 @@ -package fr.free.nrw.commons.widget; - -import android.app.Activity; -import android.content.Context; -import android.util.AttributeSet; -import android.util.DisplayMetrics; - -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -/** - * Created by Ilgaz Er on 8/7/2018. - */ -public class HeightLimitedRecyclerView extends RecyclerView { - int height; - public HeightLimitedRecyclerView(Context context) { - super(context); - DisplayMetrics displayMetrics = new DisplayMetrics(); - ((Activity) getContext()).getWindowManager() - .getDefaultDisplay() - .getMetrics(displayMetrics); - height=displayMetrics.heightPixels; - } - - public HeightLimitedRecyclerView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - DisplayMetrics displayMetrics = new DisplayMetrics(); - ((Activity) getContext()).getWindowManager() - .getDefaultDisplay() - .getMetrics(displayMetrics); - height=displayMetrics.heightPixels; - } - - public HeightLimitedRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - DisplayMetrics displayMetrics = new DisplayMetrics(); - ((Activity) getContext()).getWindowManager() - .getDefaultDisplay() - .getMetrics(displayMetrics); - height=displayMetrics.heightPixels; - } - - @Override - protected void onMeasure(int widthSpec, int heightSpec) { - heightSpec = MeasureSpec.makeMeasureSpec((int) (height*0.3), MeasureSpec.AT_MOST); - super.onMeasure(widthSpec, heightSpec); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/widget/HeightLimitedRecyclerView.kt b/app/src/main/java/fr/free/nrw/commons/widget/HeightLimitedRecyclerView.kt new file mode 100644 index 000000000..b86455243 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/widget/HeightLimitedRecyclerView.kt @@ -0,0 +1,43 @@ +package fr.free.nrw.commons.widget + +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import android.util.DisplayMetrics + +import androidx.annotation.Nullable +import androidx.recyclerview.widget.RecyclerView + + +/** + * Created by Ilgaz Er on 8/7/2018. + */ +class HeightLimitedRecyclerView : RecyclerView { + private var height: Int = 0 + + constructor(context: Context) : super(context) { + initializeHeight(context) + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + initializeHeight(context) + } + + constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) { + initializeHeight(context) + } + + private fun initializeHeight(context: Context) { + val displayMetrics = DisplayMetrics() + (context as Activity).windowManager.defaultDisplay.getMetrics(displayMetrics) + height = displayMetrics.heightPixels + } + + override fun onMeasure(widthSpec: Int, heightSpec: Int) { + val limitedHeightSpec = MeasureSpec.makeMeasureSpec( + (height * 0.3).toInt(), + MeasureSpec.AT_MOST + ) + super.onMeasure(widthSpec, limitedHeightSpec) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.java b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.java deleted file mode 100644 index 273452078..000000000 --- a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.java +++ /dev/null @@ -1,181 +0,0 @@ -package fr.free.nrw.commons.widget; - -import android.app.PendingIntent; -import android.appwidget.AppWidgetManager; -import android.appwidget.AppWidgetProvider; -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.net.Uri; -import android.os.Build; -import android.widget.RemoteViews; -import androidx.annotation.Nullable; -import com.facebook.common.executors.CallerThreadExecutor; -import com.facebook.common.references.CloseableReference; -import com.facebook.datasource.DataSource; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.imagepipeline.core.ImagePipeline; -import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; -import com.facebook.imagepipeline.image.CloseableImage; -import com.facebook.imagepipeline.request.ImageRequest; -import com.facebook.imagepipeline.request.ImageRequestBuilder; -import fr.free.nrw.commons.media.MediaClient; -import javax.inject.Inject; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.contributions.MainActivity; -import fr.free.nrw.commons.di.ApplicationlessInjection; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import timber.log.Timber; - -import static android.content.Intent.ACTION_VIEW; - -/** - * Implementation of App Widget functionality. - */ -public class PicOfDayAppWidget extends AppWidgetProvider { - - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - - @Inject - MediaClient mediaClient; - - void updateAppWidget( - final Context context, - final AppWidgetManager appWidgetManager, - final int appWidgetId - ) { - final RemoteViews views = new RemoteViews( - context.getPackageName(), R.layout.pic_of_day_app_widget); - - // Launch App on Button Click - final Intent viewIntent = new Intent(context, MainActivity.class); - int flags = PendingIntent.FLAG_UPDATE_CURRENT; - if (Build.VERSION.SDK_INT>= Build.VERSION_CODES.M) { - flags |= PendingIntent.FLAG_IMMUTABLE; - } - final PendingIntent pendingIntent = PendingIntent.getActivity( - context, 0, viewIntent, flags); - - views.setOnClickPendingIntent(R.id.camera_button, pendingIntent); - appWidgetManager.updateAppWidget(appWidgetId, views); - - loadPictureOfTheDay(context, views, appWidgetManager, appWidgetId); - } - - /** - * Loads the picture of the day using media wiki API - * @param context The application context. - * @param views The RemoteViews object used to update the App Widget UI. - * @param appWidgetManager The AppWidgetManager instance for managing the widget. - * @param appWidgetId he ID of the App Widget to update. - */ - private void loadPictureOfTheDay( - final Context context, - final RemoteViews views, - final AppWidgetManager appWidgetManager, - final int appWidgetId - ) { - compositeDisposable.add(mediaClient.getPictureOfTheDay() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - response -> { - if (response != null) { - views.setTextViewText(R.id.appwidget_title, response.getDisplayTitle()); - - // View in browser - final Intent viewIntent = new Intent(); - viewIntent.setAction(ACTION_VIEW); - viewIntent.setData(Uri.parse(response.getPageTitle().getMobileUri())); - - int flags = PendingIntent.FLAG_UPDATE_CURRENT; - if (Build.VERSION.SDK_INT>= Build.VERSION_CODES.M) { - flags |= PendingIntent.FLAG_IMMUTABLE; - } - final PendingIntent pendingIntent = PendingIntent.getActivity( - context, 0, viewIntent, flags); - - views.setOnClickPendingIntent(R.id.appwidget_image, pendingIntent); - loadImageFromUrl(response.getThumbUrl(), - context, views, appWidgetManager, appWidgetId); - } - }, - t -> Timber.e(t, "Fetching picture of the day failed") - )); - } - - /** - * Uses Fresco to load an image from Url - * @param imageUrl The URL of the image to load. - * @param context The application context. - * @param views The RemoteViews object used to update the App Widget UI. - * @param appWidgetManager The AppWidgetManager instance for managing the widget. - * @param appWidgetId he ID of the App Widget to update. - */ - private void loadImageFromUrl( - final String imageUrl, - final Context context, - final RemoteViews views, - final AppWidgetManager appWidgetManager, - final int appWidgetId - ) { - final ImageRequest request = ImageRequestBuilder - .newBuilderWithSource(Uri.parse(imageUrl)).build(); - final ImagePipeline imagePipeline = Fresco.getImagePipeline(); - final DataSource> dataSource = imagePipeline - .fetchDecodedImage(request, context); - - dataSource.subscribe(new BaseBitmapDataSubscriber() { - @Override - protected void onNewResultImpl(@Nullable final Bitmap tempBitmap) { - Bitmap bitmap = null; - if (tempBitmap != null) { - bitmap = Bitmap.createBitmap( - tempBitmap.getWidth(), tempBitmap.getHeight(), Bitmap.Config.ARGB_8888 - ); - final Canvas canvas = new Canvas(bitmap); - canvas.drawBitmap(tempBitmap, 0f, 0f, new Paint()); - } - views.setImageViewBitmap(R.id.appwidget_image, bitmap); - appWidgetManager.updateAppWidget(appWidgetId, views); - } - - @Override - protected void onFailureImpl( - final DataSource> dataSource - ) { - // Ignore failure for now. - } - }, CallerThreadExecutor.getInstance()); - } - - @Override - public void onUpdate( - final Context context, - final AppWidgetManager appWidgetManager, - final int[] appWidgetIds - ) { - ApplicationlessInjection - .getInstance(context.getApplicationContext()) - .getCommonsApplicationComponent() - .inject(this); - // There may be multiple widgets active, so update all of them - for (final int appWidgetId : appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId); - } - } - - @Override - public void onEnabled(final Context context) { - // Enter relevant functionality for when the first widget is created - } - - @Override - public void onDisabled(final Context context) { - // Enter relevant functionality for when the last widget is disabled - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt new file mode 100644 index 000000000..ab6a45b85 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/widget/PicOfDayAppWidget.kt @@ -0,0 +1,174 @@ +package fr.free.nrw.commons.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.net.Uri +import android.os.Build +import android.widget.RemoteViews +import androidx.annotation.Nullable +import com.facebook.common.executors.CallerThreadExecutor +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.core.ImagePipeline +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.request.ImageRequest +import com.facebook.imagepipeline.request.ImageRequestBuilder +import fr.free.nrw.commons.media.MediaClient +import javax.inject.Inject +import fr.free.nrw.commons.R +import fr.free.nrw.commons.contributions.MainActivity +import fr.free.nrw.commons.di.ApplicationlessInjection +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber + +/** + * Implementation of App Widget functionality. + */ +class PicOfDayAppWidget : AppWidgetProvider() { + + private val compositeDisposable = CompositeDisposable() + + @Inject + lateinit var mediaClient: MediaClient + + private fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + val views = RemoteViews(context.packageName, R.layout.pic_of_day_app_widget) + + // Launch App on Button Click + val viewIntent = Intent(context, MainActivity::class.java) + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + val pendingIntent = PendingIntent.getActivity(context, 0, viewIntent, flags) + views.setOnClickPendingIntent(R.id.camera_button, pendingIntent) + + appWidgetManager.updateAppWidget(appWidgetId, views) + + loadPictureOfTheDay(context, views, appWidgetManager, appWidgetId) + } + + /** + * Loads the picture of the day using media wiki API + * @param context The application context. + * @param views The RemoteViews object used to update the App Widget UI. + * @param appWidgetManager The AppWidgetManager instance for managing the widget. + * @param appWidgetId The ID of the App Widget to update. + */ + private fun loadPictureOfTheDay( + context: Context, + views: RemoteViews, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + compositeDisposable.add( + mediaClient.getPictureOfTheDay() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + if (response != null) { + views.setTextViewText(R.id.appwidget_title, response.displayTitle) + + // View in browser + val viewIntent = Intent().apply { + action = Intent.ACTION_VIEW + data = Uri.parse(response.pageTitle.mobileUri) + } + + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + val pendingIntent = PendingIntent.getActivity( + context, + 0, + viewIntent, + flags + ) + + views.setOnClickPendingIntent(R.id.appwidget_image, pendingIntent) + loadImageFromUrl( + response.thumbUrl, + context, + views, + appWidgetManager, + appWidgetId + ) + } + }, + { t -> Timber.e(t, "Fetching picture of the day failed") } + ) + ) + } + + /** + * Uses Fresco to load an image from Url + * @param imageUrl The URL of the image to load. + * @param context The application context. + * @param views The RemoteViews object used to update the App Widget UI. + * @param appWidgetManager The AppWidgetManager instance for managing the widget. + * @param appWidgetId The ID of the App Widget to update. + */ + private fun loadImageFromUrl( + imageUrl: String?, + context: Context, + views: RemoteViews, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + val request = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageUrl)).build() + val imagePipeline = Fresco.getImagePipeline() + val dataSource = imagePipeline.fetchDecodedImage(request, context) + + dataSource.subscribe(object : BaseBitmapDataSubscriber() { + override fun onNewResultImpl(tempBitmap: Bitmap?) { + val bitmap = tempBitmap?.let { + Bitmap.createBitmap(it.width, it.height, Bitmap.Config.ARGB_8888).apply { + Canvas(this).drawBitmap(it, 0f, 0f, Paint()) + } + } + views.setImageViewBitmap(R.id.appwidget_image, bitmap) + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + override fun onFailureImpl(dataSource: DataSource>) { + // Ignore failure for now. + } + }, CallerThreadExecutor.getInstance()) + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + ApplicationlessInjection + .getInstance(context.applicationContext) + .commonsApplicationComponent + .inject(this) + + // There may be multiple widgets active, so update all of them + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId) + } + } + + override fun onEnabled(context: Context) { + // Enter relevant functionality for when the first widget is created + } + + override fun onDisabled(context: Context) { + // Enter relevant functionality for when the last widget is disabled + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/widget/ViewHolder.java b/app/src/main/java/fr/free/nrw/commons/widget/ViewHolder.java deleted file mode 100644 index e2dd8d680..000000000 --- a/app/src/main/java/fr/free/nrw/commons/widget/ViewHolder.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.widget; - -import android.content.Context; - -public interface ViewHolder { - void bindModel(Context context, T model); -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/widget/ViewHolder.kt b/app/src/main/java/fr/free/nrw/commons/widget/ViewHolder.kt new file mode 100644 index 000000000..f9f598b3e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/widget/ViewHolder.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.widget + +import android.content.Context + +interface ViewHolder { + fun bindModel(context: Context, model: T) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt index 39dbf0cad..bc0ba24fa 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt @@ -8,24 +8,19 @@ import retrofit2.converter.gson.GsonConverterFactory class CommonsServiceFactory( private val okHttpClient: OkHttpClient, ) { - private val builder: Retrofit.Builder by lazy { + val builder: Retrofit.Builder by lazy { // All instances of retrofit share this configuration, but create it lazily - Retrofit - .Builder() + Retrofit.Builder() .client(okHttpClient) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) + .addConverterFactory(GsonConverterFactory.create(GsonUtil.defaultGson)) } - private val retrofitCache: MutableMap = mutableMapOf() + val retrofitCache: MutableMap = mutableMapOf() - fun create( - baseUrl: String, - service: Class, - ): T = - retrofitCache - .getOrPut(baseUrl) { - // Cache instances of retrofit based on API backend - builder.baseUrl(baseUrl).build() - }.create(service) + inline fun create(baseUrl: String): T = + retrofitCache.getOrPut(baseUrl) { + // Cache instances of retrofit based on API backend + builder.baseUrl(baseUrl).build() + }.create(T::class.java) } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java b/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java deleted file mode 100644 index c9d37eda5..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java +++ /dev/null @@ -1,34 +0,0 @@ -package fr.free.nrw.commons.wikidata; - -import android.net.Uri; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory; -import fr.free.nrw.commons.wikidata.model.DataValue; -import fr.free.nrw.commons.wikidata.model.WikiSite; -import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter; -import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter; -import fr.free.nrw.commons.wikidata.json.UriTypeAdapter; -import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter; -import fr.free.nrw.commons.wikidata.model.page.Namespace; - -public final class GsonUtil { - private static final String DATE_FORMAT = "MMM dd, yyyy HH:mm:ss"; - - private static final GsonBuilder DEFAULT_GSON_BUILDER = new GsonBuilder() - .setDateFormat(DATE_FORMAT) - .registerTypeAdapterFactory(DataValue.getPolymorphicTypeAdapter()) - .registerTypeHierarchyAdapter(Uri.class, new UriTypeAdapter().nullSafe()) - .registerTypeHierarchyAdapter(Namespace.class, new NamespaceTypeAdapter().nullSafe()) - .registerTypeAdapter(WikiSite.class, new WikiSiteTypeAdapter().nullSafe()) - .registerTypeAdapterFactory(new RequiredFieldsCheckOnReadTypeAdapterFactory()) - .registerTypeAdapterFactory(new PostProcessingTypeAdapter()); - - private static final Gson DEFAULT_GSON = DEFAULT_GSON_BUILDER.create(); - - public static Gson getDefaultGson() { - return DEFAULT_GSON; - } - - private GsonUtil() { } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt new file mode 100644 index 000000000..1a0ae0aeb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt @@ -0,0 +1,29 @@ +package fr.free.nrw.commons.wikidata + +import android.net.Uri +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter +import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter +import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory +import fr.free.nrw.commons.wikidata.json.UriTypeAdapter +import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter +import fr.free.nrw.commons.wikidata.model.DataValue.Companion.polymorphicTypeAdapter +import fr.free.nrw.commons.wikidata.model.WikiSite +import fr.free.nrw.commons.wikidata.model.page.Namespace + +object GsonUtil { + private const val DATE_FORMAT = "MMM dd, yyyy HH:mm:ss" + + private val DEFAULT_GSON_BUILDER: GsonBuilder by lazy { + GsonBuilder().setDateFormat(DATE_FORMAT) + .registerTypeAdapterFactory(polymorphicTypeAdapter) + .registerTypeHierarchyAdapter(Uri::class.java, UriTypeAdapter().nullSafe()) + .registerTypeHierarchyAdapter(Namespace::class.java, NamespaceTypeAdapter().nullSafe()) + .registerTypeAdapter(WikiSite::class.java, WikiSiteTypeAdapter().nullSafe()) + .registerTypeAdapterFactory(RequiredFieldsCheckOnReadTypeAdapterFactory()) + .registerTypeAdapterFactory(PostProcessingTypeAdapter()) + } + + val defaultGson: Gson by lazy { DEFAULT_GSON_BUILDER.create() } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java deleted file mode 100644 index f89b5aee0..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java +++ /dev/null @@ -1,11 +0,0 @@ -package fr.free.nrw.commons.wikidata; - -public class WikidataConstants { - public static final String PLACE_OBJECT = "place"; - public static final String BOOKMARKS_ITEMS = "bookmarks.items"; - public static final String SELECTED_NEARBY_PLACE = "selected.nearby.place"; - public static final String SELECTED_NEARBY_PLACE_CATEGORY = "selected.nearby.place.category"; - - public static final String MW_API_PREFIX = "w/api.php?format=json&formatversion=2&errorformat=plaintext&"; - public static final String WIKIPEDIA_URL = "https://wikipedia.org/"; -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt new file mode 100644 index 000000000..6343342cb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt @@ -0,0 +1,11 @@ +package fr.free.nrw.commons.wikidata + +object WikidataConstants { + const val PLACE_OBJECT: String = "place" + const val BOOKMARKS_ITEMS: String = "bookmarks.items" + const val SELECTED_NEARBY_PLACE: String = "selected.nearby.place" + const val SELECTED_NEARBY_PLACE_CATEGORY: String = "selected.nearby.place.category" + + const val MW_API_PREFIX: String = "w/api.php?format=json&formatversion=2&errorformat=plaintext&" + const val WIKIPEDIA_URL: String = "https://wikipedia.org/" +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java deleted file mode 100644 index 30fb26ddc..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java +++ /dev/null @@ -1,16 +0,0 @@ -package fr.free.nrw.commons.wikidata; - -public abstract class WikidataEditListener { - - protected WikidataP18EditListener wikidataP18EditListener; - - public abstract void onSuccessfulWikidataEdit(); - - public void setAuthenticationStateListener(WikidataP18EditListener wikidataP18EditListener) { - this.wikidataP18EditListener = wikidataP18EditListener; - } - - public interface WikidataP18EditListener { - void onWikidataEditSuccessful(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt new file mode 100644 index 000000000..5e382b4ce --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt @@ -0,0 +1,11 @@ +package fr.free.nrw.commons.wikidata + +abstract class WikidataEditListener { + var authenticationStateListener: WikidataP18EditListener? = null + + abstract fun onSuccessfulWikidataEdit() + + interface WikidataP18EditListener { + fun onWikidataEditSuccessful() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java deleted file mode 100644 index a97d0eded..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java +++ /dev/null @@ -1,20 +0,0 @@ -package fr.free.nrw.commons.wikidata; - -/** - * Listener for wikidata edits - */ -public class WikidataEditListenerImpl extends WikidataEditListener { - - public WikidataEditListenerImpl() { - } - - /** - * Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired - */ - @Override - public void onSuccessfulWikidataEdit() { - if (wikidataP18EditListener != null) { - wikidataP18EditListener.onWikidataEditSuccessful(); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt new file mode 100644 index 000000000..6827ab30c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.wikidata + +/** + * Listener for wikidata edits + */ +class WikidataEditListenerImpl : WikidataEditListener() { + /** + * Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired + */ + override fun onSuccessfulWikidataEdit() { + authenticationStateListener?.onWikidataEditSuccessful() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java deleted file mode 100644 index 21567f5e4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java +++ /dev/null @@ -1,271 +0,0 @@ -package fr.free.nrw.commons.wikidata; - - -import static fr.free.nrw.commons.media.MediaClientKt.PAGE_ID_PREFIX; - -import android.annotation.SuppressLint; -import android.content.Context; -import androidx.annotation.Nullable; -import com.google.gson.Gson; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.upload.UploadResult; -import fr.free.nrw.commons.upload.WikidataItem; -import fr.free.nrw.commons.upload.WikidataPlace; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.wikidata.model.DataValue; -import fr.free.nrw.commons.wikidata.model.DataValue.ValueString; -import fr.free.nrw.commons.wikidata.model.EditClaim; -import fr.free.nrw.commons.wikidata.model.RemoveClaim; -import fr.free.nrw.commons.wikidata.model.SnakPartial; -import fr.free.nrw.commons.wikidata.model.StatementPartial; -import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue; -import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse; -import io.reactivex.Observable; -import io.reactivex.schedulers.Schedulers; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.UUID; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; -import timber.log.Timber; - -/** - * This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki - * Apis to make the necessary calls, log the edits and fire listeners on successful edits - */ -@Singleton -public class WikidataEditService { - - public static final String COMMONS_APP_TAG = "wikimedia-commons-app"; - - private final Context context; - private final WikidataEditListener wikidataEditListener; - private final JsonKvStore directKvStore; - private final WikiBaseClient wikiBaseClient; - private final WikidataClient wikidataClient; - private final Gson gson; - - @Inject - public WikidataEditService(final Context context, - final WikidataEditListener wikidataEditListener, - @Named("default_preferences") final JsonKvStore directKvStore, - final WikiBaseClient wikiBaseClient, - final WikidataClient wikidataClient, final Gson gson) { - this.context = context; - this.wikidataEditListener = wikidataEditListener; - this.directKvStore = directKvStore; - this.wikiBaseClient = wikiBaseClient; - this.wikidataClient = wikidataClient; - this.gson = gson; - } - - /** - * Edits the wikibase entity by adding DEPICTS property. Adding DEPICTS property requires call - * to the wikibase API to set tag against the entity. - */ - @SuppressLint("CheckResult") - private Observable addDepictsProperty( - final String fileEntityId, - final List depictedItems - ) { - final EditClaim data = editClaim( - ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") - // Wikipedia:Sandbox (Q10) - : depictedItems - ); - - return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) - .doOnNext(success -> { - if (success) { - Timber.d("DEPICTS property was set successfully for %s", fileEntityId); - } else { - Timber.d("Unable to set DEPICTS property for %s", fileEntityId); - } - }) - .doOnError(throwable -> { - Timber.e(throwable, "Error occurred while setting DEPICTS property"); - ViewUtil.showLongToast(context, throwable.toString()); - }) - .subscribeOn(Schedulers.io()); - } - - /** - * Takes depicts ID as a parameter and create a uploadable data with the Id - * and send the data for POST operation - * - * @param fileEntityId ID of the file - * @param depictedItems IDs of the selected depict item - * @return Observable - */ - @SuppressLint("CheckResult") - public Observable updateDepictsProperty( - final String fileEntityId, - final List depictedItems - ) { - final String entityId = PAGE_ID_PREFIX + fileEntityId; - final List claimIds = getDepictionsClaimIds(entityId); - - final RemoveClaim data = removeClaim( /* Please consider removeClaim scenario for BetaDebug */ - ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") - // Wikipedia:Sandbox (Q10) - : claimIds - ); - - return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data)) - .doOnError(throwable -> { - Timber.e(throwable, "Error occurred while removing existing claims for DEPICTS property"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }).switchMap(success-> { - if(success) { - Timber.d("DEPICTS property was deleted successfully"); - return addDepictsProperty(fileEntityId, depictedItems); - } else { - Timber.d("Unable to delete DEPICTS property"); - return Observable.empty(); - } - }); - } - - @SuppressLint("CheckResult") - private List getDepictionsClaimIds(final String entityId) { - return wikiBaseClient.getClaimIdsByProperty(entityId, WikidataProperties.DEPICTS.getPropertyName()) - .subscribeOn(Schedulers.io()) - .blockingFirst(); - } - - private EditClaim editClaim(final List entityIds) { - return EditClaim.from(entityIds, WikidataProperties.DEPICTS.getPropertyName()); - } - - private RemoveClaim removeClaim(final List claimIds) { - return RemoveClaim.from(claimIds); - } - - /** - * Show a success toast when the edit is made successfully - */ - private void showSuccessToast(final String wikiItemName) { - final String successStringTemplate = context.getString(R.string.successful_wikidata_edit); - final String successMessage = String - .format(Locale.getDefault(), successStringTemplate, wikiItemName); - ViewUtil.showLongToast(context, successMessage); - } - - /** - * Adds label to Wikidata using the fileEntityId and the edit token, obtained from - * csrfTokenClient - * - * @param fileEntityId - * @return - */ - @SuppressLint("CheckResult") - private Observable addCaption(final long fileEntityId, final String languageCode, - final String captionValue) { - return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue) - .doOnNext(mwPostResponse -> onAddCaptionResponse(fileEntityId, mwPostResponse)) - .doOnError(throwable -> { - Timber.e(throwable, "Error occurred while setting Captions"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }) - .map(mwPostResponse -> mwPostResponse != null); - } - - private void onAddCaptionResponse(Long fileEntityId, MwPostResponse response) { - if (response != null) { - Timber.d("Caption successfully set, revision id = %s", response); - } else { - Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId); - } - } - - public Long createClaim(@Nullable final WikidataPlace wikidataPlace, final String fileName, - final Map captions) { - if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { - Timber - .d("Image location and nearby place location mismatched, so Wikidata item won't be edited"); - return null; - } - return addImageAndMediaLegends(wikidataPlace, fileName, captions); - } - - public Long addImageAndMediaLegends(final WikidataItem wikidataItem, final String fileName, - final Map captions) { - final SnakPartial p18 = new SnakPartial("value", - WikidataProperties.IMAGE.getPropertyName(), - new ValueString(fileName.replace("File:", ""))); - - final List snaks = new ArrayList<>(); - for (final Map.Entry entry : captions.entrySet()) { - snaks.add(new SnakPartial("value", - WikidataProperties.MEDIA_LEGENDS.getPropertyName(), new DataValue.MonoLingualText( - new WikiBaseMonolingualTextValue(entry.getValue(), entry.getKey())))); - } - - final String id = wikidataItem.getId() + "$" + UUID.randomUUID().toString(); - final StatementPartial claim = new StatementPartial(p18, "statement", "normal", id, - Collections.singletonMap(WikidataProperties.MEDIA_LEGENDS.getPropertyName(), snaks), - Arrays.asList(WikidataProperties.MEDIA_LEGENDS.getPropertyName())); - - return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle(); - } - - public void handleImageClaimResult(final WikidataItem wikidataItem, final Long revisionId) { - if (revisionId != null) { - if (wikidataEditListener != null) { - wikidataEditListener.onSuccessfulWikidataEdit(); - } - showSuccessToast(wikidataItem.getName()); - } else { - Timber.d("Unable to make wiki data edit for entity %s", wikidataItem); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - } - } - - public Observable addDepictionsAndCaptions( - final UploadResult uploadResult, - final Contribution contribution - ) { - return wikiBaseClient.getFileEntityId(uploadResult) - .doOnError(throwable -> { - Timber - .e(throwable, "Error occurred while getting EntityID to set DEPICTS property"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }) - .switchMap(fileEntityId -> { - if (fileEntityId != null) { - Timber.d("EntityId for image was received successfully: %s", fileEntityId); - return Observable.concat( - depictionEdits(contribution, fileEntityId), - captionEdits(contribution, fileEntityId) - ); - } else { - Timber.d("Error acquiring EntityId for image: %s", uploadResult); - return Observable.empty(); - } - } - ); - } - - private Observable captionEdits(Contribution contribution, Long fileEntityId) { - return Observable.fromIterable(contribution.getMedia().getCaptions().entrySet()) - .concatMap(entry -> addCaption(fileEntityId, entry.getKey(), entry.getValue())); - } - - private Observable depictionEdits(Contribution contribution, Long fileEntityId) { - final List depictIDs = new ArrayList<>(); - for (final WikidataItem wikidataItem : - contribution.getDepictedItems()) { - depictIDs.add(wikidataItem.getId()); - } - return addDepictsProperty(fileEntityId.toString(), depictIDs); - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt new file mode 100644 index 000000000..396f92824 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt @@ -0,0 +1,252 @@ +package fr.free.nrw.commons.wikidata + +import android.annotation.SuppressLint +import android.content.Context +import com.google.gson.Gson +import fr.free.nrw.commons.R +import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.media.PAGE_ID_PREFIX +import fr.free.nrw.commons.upload.UploadResult +import fr.free.nrw.commons.upload.WikidataItem +import fr.free.nrw.commons.upload.WikidataPlace +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import fr.free.nrw.commons.wikidata.WikidataProperties.DEPICTS +import fr.free.nrw.commons.wikidata.WikidataProperties.IMAGE +import fr.free.nrw.commons.wikidata.WikidataProperties.MEDIA_LEGENDS +import fr.free.nrw.commons.wikidata.model.DataValue.MonoLingualText +import fr.free.nrw.commons.wikidata.model.DataValue.ValueString +import fr.free.nrw.commons.wikidata.model.EditClaim +import fr.free.nrw.commons.wikidata.model.RemoveClaim +import fr.free.nrw.commons.wikidata.model.SnakPartial +import fr.free.nrw.commons.wikidata.model.StatementPartial +import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue +import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse +import io.reactivex.Observable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Arrays +import java.util.Collections +import java.util.Locale +import java.util.Objects +import java.util.UUID +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + + +/** + * This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki + * Apis to make the necessary calls, log the edits and fire listeners on successful edits + */ +@Singleton +class WikidataEditService @Inject constructor( + private val context: Context, + private val wikidataEditListener: WikidataEditListener?, + @param:Named("default_preferences") private val directKvStore: JsonKvStore, + private val wikiBaseClient: WikiBaseClient, + private val wikidataClient: WikidataClient, private val gson: Gson +) { + @SuppressLint("CheckResult") + private fun addDepictsProperty( + fileEntityId: String, + depictedItems: List + ): Observable { + val data = EditClaim.from( + if (isBetaFlavour) listOf("Q10") else depictedItems, DEPICTS.propertyName + ) + + return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) + .doOnNext { success: Boolean -> + if (success) { + Timber.d("DEPICTS property was set successfully for %s", fileEntityId) + } else { + Timber.d("Unable to set DEPICTS property for %s", fileEntityId) + } + } + .doOnError { throwable: Throwable -> + Timber.e(throwable, "Error occurred while setting DEPICTS property") + showLongToast(context, throwable.toString()) + } + .subscribeOn(Schedulers.io()) + } + + @SuppressLint("CheckResult") + fun updateDepictsProperty( + fileEntityId: String?, + depictedItems: List + ): Observable { + val entityId: String = PAGE_ID_PREFIX + fileEntityId + val claimIds = getDepictionsClaimIds(entityId) + + /* Please consider removeClaim scenario for BetaDebug */ + val data = RemoveClaim.from(if (isBetaFlavour) listOf("Q10") else claimIds) + + return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data)) + .doOnError { throwable: Throwable? -> + Timber.e( + throwable, + "Error occurred while removing existing claims for DEPICTS property" + ) + showLongToast( + context, + context.getString(R.string.wikidata_edit_failure) + ) + }.switchMap { success: Boolean -> + if (success) { + Timber.d("DEPICTS property was deleted successfully") + return@switchMap addDepictsProperty(fileEntityId!!, depictedItems) + } else { + Timber.d("Unable to delete DEPICTS property") + return@switchMap Observable.empty() + } + } + } + + @SuppressLint("CheckResult") + private fun getDepictionsClaimIds(entityId: String): List { + return wikiBaseClient.getClaimIdsByProperty(entityId, DEPICTS.propertyName) + .subscribeOn(Schedulers.io()) + .blockingFirst() + } + + private fun showSuccessToast(wikiItemName: String) { + val successStringTemplate = context.getString(R.string.successful_wikidata_edit) + val successMessage = String.format(Locale.getDefault(), successStringTemplate, wikiItemName) + showLongToast(context, successMessage) + } + + @SuppressLint("CheckResult") + private fun addCaption( + fileEntityId: Long, languageCode: String, + captionValue: String + ): Observable { + return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue) + .doOnNext { mwPostResponse: MwPostResponse? -> + onAddCaptionResponse( + fileEntityId, + mwPostResponse + ) + } + .doOnError { throwable: Throwable? -> + Timber.e(throwable, "Error occurred while setting Captions") + showLongToast( + context, + context.getString(R.string.wikidata_edit_failure) + ) + } + .map(Objects::nonNull) + } + + private fun onAddCaptionResponse(fileEntityId: Long, response: MwPostResponse?) { + if (response != null) { + Timber.d("Caption successfully set, revision id = %s", response) + } else { + Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId) + } + } + + fun createClaim( + wikidataPlace: WikidataPlace?, fileName: String, + captions: Map + ): Long? { + if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { + Timber.d( + "Image location and nearby place location mismatched, so Wikidata item won't be edited" + ) + return null + } + return addImageAndMediaLegends(wikidataPlace!!, fileName, captions) + } + + fun addImageAndMediaLegends( + wikidataItem: WikidataItem, fileName: String, + captions: Map + ): Long { + val p18 = SnakPartial( + "value", + IMAGE.propertyName, + ValueString(fileName.replace("File:", "")) + ) + + val snaks: MutableList = ArrayList() + for ((key, value) in captions) { + snaks.add( + SnakPartial( + "value", + MEDIA_LEGENDS.propertyName, MonoLingualText( + WikiBaseMonolingualTextValue(value!!, key!!) + ) + ) + ) + } + + val id = wikidataItem.id + "$" + UUID.randomUUID().toString() + val claim = StatementPartial( + p18, "statement", "normal", id, Collections.singletonMap>( + MEDIA_LEGENDS.propertyName, snaks + ), Arrays.asList(MEDIA_LEGENDS.propertyName) + ) + + return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle() + } + + fun handleImageClaimResult(wikidataItem: WikidataItem, revisionId: Long?) { + if (revisionId != null) { + wikidataEditListener?.onSuccessfulWikidataEdit() + showSuccessToast(wikidataItem.name) + } else { + Timber.d("Unable to make wiki data edit for entity %s", wikidataItem) + showLongToast(context, context.getString(R.string.wikidata_edit_failure)) + } + } + + fun addDepictionsAndCaptions( + uploadResult: UploadResult, + contribution: Contribution + ): Observable { + return wikiBaseClient.getFileEntityId(uploadResult) + .doOnError { throwable: Throwable? -> + Timber.e( + throwable, + "Error occurred while getting EntityID to set DEPICTS property" + ) + showLongToast( + context, + context.getString(R.string.wikidata_edit_failure) + ) + } + .switchMap { fileEntityId: Long? -> + if (fileEntityId != null) { + Timber.d("EntityId for image was received successfully: %s", fileEntityId) + return@switchMap Observable.concat( + depictionEdits(contribution, fileEntityId), + captionEdits(contribution, fileEntityId) + ) + } else { + Timber.d("Error acquiring EntityId for image: %s", uploadResult) + return@switchMap Observable.empty() + } + } + } + + private fun captionEdits(contribution: Contribution, fileEntityId: Long): Observable { + return Observable.fromIterable(contribution.media.captions.entries) + .concatMap { addCaption(fileEntityId, it.key, it.value) } + } + + private fun depictionEdits( + contribution: Contribution, + fileEntityId: Long + ): Observable = addDepictsProperty(fileEntityId.toString(), buildList { + for ((_, _, _, _, _, _, id) in contribution.depictedItems) { + add(id) + } + }) + + companion object { + const val COMMONS_APP_TAG: String = "wikimedia-commons-app" + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java deleted file mode 100644 index cc6dcc9f9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java +++ /dev/null @@ -1,29 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonToken; -import com.google.gson.stream.JsonWriter; - -import fr.free.nrw.commons.wikidata.model.page.Namespace; - -import java.io.IOException; - -public class NamespaceTypeAdapter extends TypeAdapter { - - @Override - public void write(JsonWriter out, Namespace namespace) throws IOException { - out.value(namespace.code()); - } - - @Override - public Namespace read(JsonReader in) throws IOException { - if (in.peek() == JsonToken.STRING) { - // Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of - // the code number. This introduces a backwards-compatible check for the string value. - // TODO: remove after April 2017, when all older namespaces have been deserialized. - return Namespace.valueOf(in.nextString()); - } - return Namespace.of(in.nextInt()); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt new file mode 100644 index 000000000..09f1dc5e8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt @@ -0,0 +1,26 @@ +package fr.free.nrw.commons.wikidata.json + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import fr.free.nrw.commons.wikidata.model.page.Namespace +import java.io.IOException + +class NamespaceTypeAdapter : TypeAdapter() { + @Throws(IOException::class) + override fun write(out: JsonWriter, namespace: Namespace) { + out.value(namespace.code().toLong()) + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): Namespace { + if (reader.peek() == JsonToken.STRING) { + // Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of + // the code number. This introduces a backwards-compatible check for the string value. + // TODO: remove after April 2017, when all older namespaces have been deserialized. + return Namespace.valueOf(reader.nextString()) + } + return Namespace.of(reader.nextInt()) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java deleted file mode 100644 index b6b67d4d2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java +++ /dev/null @@ -1,34 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import com.google.gson.Gson; -import com.google.gson.TypeAdapter; -import com.google.gson.TypeAdapterFactory; -import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -public class PostProcessingTypeAdapter implements TypeAdapterFactory { - public interface PostProcessable { - void postProcess(); - } - - public TypeAdapter create(Gson gson, TypeToken type) { - final TypeAdapter delegate = gson.getDelegateAdapter(this, type); - - return new TypeAdapter() { - public void write(JsonWriter out, T value) throws IOException { - delegate.write(out, value); - } - - public T read(JsonReader in) throws IOException { - T obj = delegate.read(in); - if (obj instanceof PostProcessable) { - ((PostProcessable)obj).postProcess(); - } - return obj; - } - }; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt new file mode 100644 index 000000000..cf07eabf4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt @@ -0,0 +1,35 @@ +package fr.free.nrw.commons.wikidata.json + +import com.google.gson.Gson +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import java.io.IOException + +class PostProcessingTypeAdapter : TypeAdapterFactory { + interface PostProcessable { + fun postProcess() + } + + override fun create(gson: Gson, type: TypeToken): TypeAdapter { + val delegate = gson.getDelegateAdapter(this, type) + + return object : TypeAdapter() { + @Throws(IOException::class) + override fun write(out: JsonWriter, value: T) { + delegate.write(out, value) + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): T { + val obj = delegate.read(reader) + if (obj is PostProcessable) { + (obj as PostProcessable).postProcess() + } + return obj + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java deleted file mode 100644 index c01b9fe66..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java +++ /dev/null @@ -1,94 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.collection.ArraySet; - -import com.google.gson.Gson; -import com.google.gson.JsonParseException; -import com.google.gson.TypeAdapter; -import com.google.gson.TypeAdapterFactory; -import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -import fr.free.nrw.commons.wikidata.json.annotations.Required; - -import java.io.IOException; -import java.lang.reflect.Field; -import java.util.Collections; -import java.util.Set; - -/** - * TypeAdapterFactory that provides TypeAdapters that return null values for objects that are - * missing fields annotated with @Required. - * - * BEWARE: This means that a List or other Collection of objects that have @Required fields can - * contain null elements after deserialization! - * - * TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements - * annotation and another corresponding TypeAdapter(Factory). - */ -public class RequiredFieldsCheckOnReadTypeAdapterFactory implements TypeAdapterFactory { - @Nullable @Override public final TypeAdapter create(@NonNull Gson gson, @NonNull TypeToken typeToken) { - Class rawType = typeToken.getRawType(); - Set requiredFields = collectRequiredFields(rawType); - - if (requiredFields.isEmpty()) { - return null; - } - - setFieldsAccessible(requiredFields, true); - return new Adapter<>(gson.getDelegateAdapter(this, typeToken), requiredFields); - } - - @NonNull private Set collectRequiredFields(@NonNull Class clazz) { - Field[] fields = clazz.getDeclaredFields(); - Set required = new ArraySet<>(); - for (Field field : fields) { - if (field.isAnnotationPresent(Required.class)) { - required.add(field); - } - } - return Collections.unmodifiableSet(required); - } - - private void setFieldsAccessible(Iterable fields, boolean accessible) { - for (Field field : fields) { - field.setAccessible(accessible); - } - } - - private static final class Adapter extends TypeAdapter { - @NonNull private final TypeAdapter delegate; - @NonNull private final Set requiredFields; - - private Adapter(@NonNull TypeAdapter delegate, @NonNull final Set requiredFields) { - this.delegate = delegate; - this.requiredFields = requiredFields; - } - - @Override public void write(JsonWriter out, T value) throws IOException { - delegate.write(out, value); - } - - @Override @Nullable public T read(JsonReader in) throws IOException { - T deserialized = delegate.read(in); - return allRequiredFieldsPresent(deserialized, requiredFields) ? deserialized : null; - } - - private boolean allRequiredFieldsPresent(@NonNull T deserialized, - @NonNull Set required) { - for (Field field : required) { - try { - if (field.get(deserialized) == null) { - return false; - } - } catch (IllegalArgumentException | IllegalAccessException e) { - throw new JsonParseException(e); - } - } - return true; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt new file mode 100644 index 000000000..ec26e8345 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt @@ -0,0 +1,75 @@ +package fr.free.nrw.commons.wikidata.json + +import com.google.gson.Gson +import com.google.gson.JsonParseException +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import fr.free.nrw.commons.wikidata.json.annotations.Required +import java.io.IOException +import java.lang.reflect.Field + +/** + * TypeAdapterFactory that provides TypeAdapters that return null values for objects that are + * missing fields annotated with @Required. + * + * BEWARE: This means that a List or other Collection of objects that have @Required fields can + * contain null elements after deserialization! + * + * TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements + * annotation and another corresponding TypeAdapter(Factory). + */ +class RequiredFieldsCheckOnReadTypeAdapterFactory : TypeAdapterFactory { + override fun create(gson: Gson, typeToken: TypeToken): TypeAdapter? { + val rawType: Class<*> = typeToken.rawType + val requiredFields = collectRequiredFields(rawType) + + if (requiredFields.isEmpty()) { + return null + } + + for (field in requiredFields) { + field.isAccessible = true + } + + return Adapter(gson.getDelegateAdapter(this, typeToken), requiredFields) + } + + private fun collectRequiredFields(clazz: Class<*>): Set = buildSet { + for (field in clazz.declaredFields) { + if (field.isAnnotationPresent(Required::class.java)) add(field) + } + } + + private class Adapter( + private val delegate: TypeAdapter, + private val requiredFields: Set + ) : TypeAdapter() { + + @Throws(IOException::class) + override fun write(out: JsonWriter, value: T?) = + delegate.write(out, value) + + @Throws(IOException::class) + override fun read(reader: JsonReader): T? = + if (allRequiredFieldsPresent(delegate.read(reader), requiredFields)) + delegate.read(reader) + else + null + + fun allRequiredFieldsPresent(deserialized: T, required: Set): Boolean { + for (field in required) { + try { + if (field[deserialized] == null) return false + } catch (e: IllegalArgumentException) { + throw JsonParseException(e) + } catch (e: IllegalAccessException) { + throw JsonParseException(e) + } + } + return true + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java deleted file mode 100644 index 828dfbd68..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java +++ /dev/null @@ -1,280 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -/* - * Copyright (C) 2011 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import android.util.Log; -import java.io.IOException; -import java.util.LinkedHashMap; -import java.util.Map; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; -import com.google.gson.JsonPrimitive; -import com.google.gson.TypeAdapter; -import com.google.gson.TypeAdapterFactory; -import com.google.gson.internal.Streams; -import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -/** - * Adapts values whose runtime type may differ from their declaration type. This - * is necessary when a field's type is not the same type that GSON should create - * when deserializing that field. For example, consider these types: - *

   {@code
- *   abstract class Shape {
- *     int x;
- *     int y;
- *   }
- *   class Circle extends Shape {
- *     int radius;
- *   }
- *   class Rectangle extends Shape {
- *     int width;
- *     int height;
- *   }
- *   class Diamond extends Shape {
- *     int width;
- *     int height;
- *   }
- *   class Drawing {
- *     Shape bottomShape;
- *     Shape topShape;
- *   }
- * }
- *

Without additional type information, the serialized JSON is ambiguous. Is - * the bottom shape in this drawing a rectangle or a diamond?

   {@code
- *   {
- *     "bottomShape": {
- *       "width": 10,
- *       "height": 5,
- *       "x": 0,
- *       "y": 0
- *     },
- *     "topShape": {
- *       "radius": 2,
- *       "x": 4,
- *       "y": 1
- *     }
- *   }}
- * This class addresses this problem by adding type information to the - * serialized JSON and honoring that type information when the JSON is - * deserialized:
   {@code
- *   {
- *     "bottomShape": {
- *       "type": "Diamond",
- *       "width": 10,
- *       "height": 5,
- *       "x": 0,
- *       "y": 0
- *     },
- *     "topShape": {
- *       "type": "Circle",
- *       "radius": 2,
- *       "x": 4,
- *       "y": 1
- *     }
- *   }}
- * Both the type field name ({@code "type"}) and the type labels ({@code - * "Rectangle"}) are configurable. - * - *

Registering Types

- * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field - * name to the {@link #of} factory method. If you don't supply an explicit type - * field name, {@code "type"} will be used.
   {@code
- *   RuntimeTypeAdapterFactory shapeAdapterFactory
- *       = RuntimeTypeAdapterFactory.of(Shape.class, "type");
- * }
- * Next register all of your subtypes. Every subtype must be explicitly - * registered. This protects your application from injection attacks. If you - * don't supply an explicit type label, the type's simple name will be used. - *
   {@code
- *   shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
- *   shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
- *   shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
- * }
- * Finally, register the type adapter factory in your application's GSON builder: - *
   {@code
- *   Gson gson = new GsonBuilder()
- *       .registerTypeAdapterFactory(shapeAdapterFactory)
- *       .create();
- * }
- * Like {@code GsonBuilder}, this API supports chaining:
   {@code
- *   RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
- *       .registerSubtype(Rectangle.class)
- *       .registerSubtype(Circle.class)
- *       .registerSubtype(Diamond.class);
- * }
- * - *

Serialization and deserialization

- * In order to serialize and deserialize a polymorphic object, - * you must specify the base type explicitly. - *
   {@code
- *   Diamond diamond = new Diamond();
- *   String json = gson.toJson(diamond, Shape.class);
- * }
- * And then: - *
   {@code
- *   Shape shape = gson.fromJson(json, Shape.class);
- * }
- */ -public final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { - private final Class baseType; - private final String typeFieldName; - private final Map> labelToSubtype = new LinkedHashMap>(); - private final Map, String> subtypeToLabel = new LinkedHashMap, String>(); - private final boolean maintainType; - - private RuntimeTypeAdapterFactory(Class baseType, String typeFieldName, boolean maintainType) { - if (typeFieldName == null || baseType == null) { - throw new NullPointerException(); - } - this.baseType = baseType; - this.typeFieldName = typeFieldName; - this.maintainType = maintainType; - } - - /** - * Creates a new runtime type adapter using for {@code baseType} using {@code - * typeFieldName} as the type field name. Type field names are case sensitive. - * {@code maintainType} flag decide if the type will be stored in pojo or not. - */ - public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName, boolean maintainType) { - return new RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType); - } - - /** - * Creates a new runtime type adapter using for {@code baseType} using {@code - * typeFieldName} as the type field name. Type field names are case sensitive. - */ - public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName) { - return new RuntimeTypeAdapterFactory(baseType, typeFieldName, false); - } - - /** - * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as - * the type field name. - */ - public static RuntimeTypeAdapterFactory of(Class baseType) { - return new RuntimeTypeAdapterFactory(baseType, "type", false); - } - - /** - * Registers {@code type} identified by {@code label}. Labels are case - * sensitive. - * - * @throws IllegalArgumentException if either {@code type} or {@code label} - * have already been registered on this type adapter. - */ - public RuntimeTypeAdapterFactory registerSubtype(Class type, String label) { - if (type == null || label == null) { - throw new NullPointerException(); - } - if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { - throw new IllegalArgumentException("types and labels must be unique"); - } - labelToSubtype.put(label, type); - subtypeToLabel.put(type, label); - return this; - } - - /** - * Registers {@code type} identified by its {@link Class#getSimpleName simple - * name}. Labels are case sensitive. - * - * @throws IllegalArgumentException if either {@code type} or its simple name - * have already been registered on this type adapter. - */ - public RuntimeTypeAdapterFactory registerSubtype(Class type) { - return registerSubtype(type, type.getSimpleName()); - } - - public TypeAdapter create(Gson gson, TypeToken type) { - if (type.getRawType() != baseType) { - return null; - } - - final Map> labelToDelegate - = new LinkedHashMap>(); - final Map, TypeAdapter> subtypeToDelegate - = new LinkedHashMap, TypeAdapter>(); - for (Map.Entry> entry : labelToSubtype.entrySet()) { - TypeAdapter delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); - labelToDelegate.put(entry.getKey(), delegate); - subtypeToDelegate.put(entry.getValue(), delegate); - } - - return new TypeAdapter() { - @Override public R read(JsonReader in) throws IOException { - JsonElement jsonElement = Streams.parse(in); - JsonElement labelJsonElement; - if (maintainType) { - labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); - } else { - labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); - } - - if (labelJsonElement == null) { - throw new JsonParseException("cannot deserialize " + baseType - + " because it does not define a field named " + typeFieldName); - } - String label = labelJsonElement.getAsString(); - @SuppressWarnings("unchecked") // registration requires that subtype extends T - TypeAdapter delegate = (TypeAdapter) labelToDelegate.get(label); - if (delegate == null) { - - Log.e("RuntimeTypeAdapter", "cannot deserialize " + baseType + " subtype named " - + label + "; did you forget to register a subtype? " +jsonElement); - return null; - } - return delegate.fromJsonTree(jsonElement); - } - - @Override public void write(JsonWriter out, R value) throws IOException { - Class srcType = value.getClass(); - String label = subtypeToLabel.get(srcType); - @SuppressWarnings("unchecked") // registration requires that subtype extends T - TypeAdapter delegate = (TypeAdapter) subtypeToDelegate.get(srcType); - if (delegate == null) { - throw new JsonParseException("cannot serialize " + srcType.getName() - + "; did you forget to register a subtype?"); - } - JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); - - if (maintainType) { - Streams.write(jsonObject, out); - return; - } - - JsonObject clone = new JsonObject(); - - if (jsonObject.has(typeFieldName)) { - throw new JsonParseException("cannot serialize " + srcType.getName() - + " because it already defines a field named " + typeFieldName); - } - clone.add(typeFieldName, new JsonPrimitive(label)); - - for (Map.Entry e : jsonObject.entrySet()) { - clone.add(e.getKey(), e.getValue()); - } - Streams.write(clone, out); - } - }.nullSafe(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt new file mode 100644 index 000000000..87acc939f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt @@ -0,0 +1,273 @@ +package fr.free.nrw.commons.wikidata.json + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonPrimitive +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.internal.Streams +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import timber.log.Timber +import java.io.IOException + +/* +* Copyright (C) 2011 Google Inc. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +/** + * Adapts values whose runtime type may differ from their declaration type. This + * is necessary when a field's type is not the same type that GSON should create + * when deserializing that field. For example, consider these types: + *
   `abstract class Shape {
+ * int x;
+ * int y;
+ * }
+ * class Circle extends Shape {
+ * int radius;
+ * }
+ * class Rectangle extends Shape {
+ * int width;
+ * int height;
+ * }
+ * class Diamond extends Shape {
+ * int width;
+ * int height;
+ * }
+ * class Drawing {
+ * Shape bottomShape;
+ * Shape topShape;
+ * }
+`
* + * + * Without additional type information, the serialized JSON is ambiguous. Is + * the bottom shape in this drawing a rectangle or a diamond?
   `{
+ * "bottomShape": {
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }`
+ * This class addresses this problem by adding type information to the + * serialized JSON and honoring that type information when the JSON is + * deserialized:
   `{
+ * "bottomShape": {
+ * "type": "Diamond",
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "type": "Circle",
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }`
+ * Both the type field name (`"type"`) and the type labels (`"Rectangle"`) are configurable. + * + *

Registering Types

+ * Create a `RuntimeTypeAdapterFactory` by passing the base type and type field + * name to the [.of] factory method. If you don't supply an explicit type + * field name, `"type"` will be used.
   `RuntimeTypeAdapterFactory shapeAdapterFactory
+ * = RuntimeTypeAdapterFactory.of(Shape.class, "type");
+`
* + * Next register all of your subtypes. Every subtype must be explicitly + * registered. This protects your application from injection attacks. If you + * don't supply an explicit type label, the type's simple name will be used. + *
   `shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
+ * shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
+ * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
+`
* + * Finally, register the type adapter factory in your application's GSON builder: + *
   `Gson gson = new GsonBuilder()
+ * .registerTypeAdapterFactory(shapeAdapterFactory)
+ * .create();
+`
* + * Like `GsonBuilder`, this API supports chaining:
   `RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
+ * .registerSubtype(Rectangle.class)
+ * .registerSubtype(Circle.class)
+ * .registerSubtype(Diamond.class);
+`
* + * + *

Serialization and deserialization

+ * In order to serialize and deserialize a polymorphic object, + * you must specify the base type explicitly. + *
   `Diamond diamond = new Diamond();
+ * String json = gson.toJson(diamond, Shape.class);
+`
* + * And then: + *
   `Shape shape = gson.fromJson(json, Shape.class);
+`
* + */ +class RuntimeTypeAdapterFactory( + baseType: Class<*>?, + typeFieldName: String?, + maintainType: Boolean +) : TypeAdapterFactory { + + private val baseType: Class<*> + private val typeFieldName: String + private val labelToSubtype = mutableMapOf>() + private val subtypeToLabel = mutableMapOf, String>() + private val maintainType: Boolean + + init { + if (typeFieldName == null || baseType == null) { + throw NullPointerException() + } + this.baseType = baseType + this.typeFieldName = typeFieldName + this.maintainType = maintainType + } + + /** + * Registers `type` identified by `label`. Labels are case + * sensitive. + * + * @throws IllegalArgumentException if either `type` or `label` + * have already been registered on this type adapter. + */ + fun registerSubtype(type: Class?, label: String?): RuntimeTypeAdapterFactory { + if (type == null || label == null) { + throw NullPointerException() + } + require(!(subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label))) { + "types and labels must be unique" + } + + labelToSubtype[label] = type + subtypeToLabel[type] = label + return this + } + + /** + * Registers `type` identified by its [simple][Class.getSimpleName]. Labels are case sensitive. + * + * @throws IllegalArgumentException if either `type` or its simple name + * have already been registered on this type adapter. + */ + fun registerSubtype(type: Class): RuntimeTypeAdapterFactory { + return registerSubtype(type, type.simpleName) + } + + override fun create(gson: Gson, type: TypeToken): TypeAdapter? { + if (type.rawType != baseType) { + return null + } + + val labelToDelegate = mutableMapOf>() + val subtypeToDelegate = mutableMapOf, TypeAdapter<*>>() + for ((key, value) in labelToSubtype) { + val delegate = gson.getDelegateAdapter( + this, TypeToken.get( + value + ) + ) + labelToDelegate[key] = delegate + subtypeToDelegate[value] = delegate + } + + return object : TypeAdapter() { + @Throws(IOException::class) + override fun read(reader: JsonReader): R? { + val jsonElement = Streams.parse(reader) + val labelJsonElement = if (maintainType) { + jsonElement.asJsonObject[typeFieldName] + } else { + jsonElement.asJsonObject.remove(typeFieldName) + } + + if (labelJsonElement == null) { + throw JsonParseException( + "cannot deserialize $baseType because it does not define a field named $typeFieldName" + ) + } + val label = labelJsonElement.asString + val delegate = labelToDelegate[label] as TypeAdapter? + if (delegate == null) { + Timber.tag("RuntimeTypeAdapter").e( + "cannot deserialize $baseType subtype named $label; did you forget to register a subtype? $jsonElement" + ) + return null + } + return delegate.fromJsonTree(jsonElement) + } + + @Throws(IOException::class) + override fun write(out: JsonWriter, value: R) { + val srcType: Class<*> = value::class.java.javaClass + val delegate = + subtypeToDelegate[srcType] as TypeAdapter? ?: throw JsonParseException( + "cannot serialize ${srcType.name}; did you forget to register a subtype?" + ) + + val jsonObject = delegate.toJsonTree(value).asJsonObject + if (maintainType) { + Streams.write(jsonObject, out) + return + } + + if (jsonObject.has(typeFieldName)) { + throw JsonParseException( + "cannot serialize ${srcType.name} because it already defines a field named $typeFieldName" + ) + } + val clone = JsonObject() + val label = subtypeToLabel[srcType] + clone.add(typeFieldName, JsonPrimitive(label)) + for ((key, value1) in jsonObject.entrySet()) { + clone.add(key, value1) + } + Streams.write(clone, out) + } + }.nullSafe() + } + + companion object { + /** + * Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive. + * `maintainType` flag decide if the type will be stored in pojo or not. + */ + fun of( + baseType: Class, + typeFieldName: String, + maintainType: Boolean + ): RuntimeTypeAdapterFactory = + RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType) + + /** + * Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive. + */ + fun of(baseType: Class, typeFieldName: String): RuntimeTypeAdapterFactory = + RuntimeTypeAdapterFactory(baseType, typeFieldName, false) + + /** + * Creates a new runtime type adapter for `baseType` using `"type"` as + * the type field name. + */ + fun of(baseType: Class): RuntimeTypeAdapterFactory = + RuntimeTypeAdapterFactory(baseType, "type", false) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java deleted file mode 100644 index 069e02f32..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java +++ /dev/null @@ -1,22 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import android.net.Uri; - -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -public class UriTypeAdapter extends TypeAdapter { - @Override - public void write(JsonWriter out, Uri value) throws IOException { - out.value(value.toString()); - } - - @Override - public Uri read(JsonReader in) throws IOException { - String url = in.nextString(); - return Uri.parse(url); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt new file mode 100644 index 000000000..305cfa28a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons.wikidata.json + +import android.net.Uri +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import java.io.IOException + +class UriTypeAdapter : TypeAdapter() { + @Throws(IOException::class) + override fun write(out: JsonWriter, value: Uri) { + out.value(value.toString()) + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): Uri { + return Uri.parse(reader.nextString()) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java deleted file mode 100644 index c268d1e73..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java +++ /dev/null @@ -1,63 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import android.net.Uri; - -import com.google.gson.JsonParseException; -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonToken; -import com.google.gson.stream.JsonWriter; - -import fr.free.nrw.commons.wikidata.model.WikiSite; - -import java.io.IOException; - -public class WikiSiteTypeAdapter extends TypeAdapter { - private static final String DOMAIN = "domain"; - private static final String LANGUAGE_CODE = "languageCode"; - - @Override public void write(JsonWriter out, WikiSite value) throws IOException { - out.beginObject(); - out.name(DOMAIN); - out.value(value.url()); - - out.name(LANGUAGE_CODE); - out.value(value.languageCode()); - out.endObject(); - } - - @Override public WikiSite read(JsonReader in) throws IOException { - // todo: legacy; remove in June 2018 - if (in.peek() == JsonToken.STRING) { - return new WikiSite(Uri.parse(in.nextString())); - } - - String domain = null; - String languageCode = null; - in.beginObject(); - while (in.hasNext()) { - String field = in.nextName(); - String val = in.nextString(); - switch (field) { - case DOMAIN: - domain = val; - break; - case LANGUAGE_CODE: - languageCode = val; - break; - default: break; - } - } - in.endObject(); - - if (domain == null) { - throw new JsonParseException("Missing domain"); - } - - // todo: legacy; remove in June 2018 - if (languageCode == null) { - return new WikiSite(domain); - } - return new WikiSite(domain, languageCode); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt new file mode 100644 index 000000000..da5cb0802 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.wikidata.json + +import android.net.Uri +import com.google.gson.JsonParseException +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import fr.free.nrw.commons.wikidata.model.WikiSite +import java.io.IOException + +class WikiSiteTypeAdapter : TypeAdapter() { + @Throws(IOException::class) + override fun write(out: JsonWriter, value: WikiSite) { + out.beginObject() + out.name(DOMAIN) + out.value(value.url()) + + out.name(LANGUAGE_CODE) + out.value(value.languageCode()) + out.endObject() + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): WikiSite { + // todo: legacy; remove reader June 2018 + if (reader.peek() == JsonToken.STRING) { + return WikiSite(Uri.parse(reader.nextString())) + } + + var domain: String? = null + var languageCode: String? = null + reader.beginObject() + while (reader.hasNext()) { + val field = reader.nextName() + val value = reader.nextString() + when (field) { + DOMAIN -> domain = value + LANGUAGE_CODE -> languageCode = value + else -> {} + } + } + reader.endObject() + + if (domain == null) { + throw JsonParseException("Missing domain") + } + + // todo: legacy; remove reader June 2018 + return if (languageCode == null) { + WikiSite(domain) + } else { + WikiSite(domain, languageCode) + } + } + + companion object { + private const val DOMAIN = "domain" + private const val LANGUAGE_CODE = "languageCode" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java deleted file mode 100644 index 98e12745b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java +++ /dev/null @@ -1,21 +0,0 @@ -package fr.free.nrw.commons.wikidata.json.annotations; - - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.FIELD; - -/** - * Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return - * an instantiated object. - * - * E.g.: @NonNull @Required private String title; - */ -@Documented -@Retention(RetentionPolicy.RUNTIME) -@Target(FIELD) -public @interface Required { -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt new file mode 100644 index 000000000..189a3a42c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.wikidata.json.annotations + + +/** + * Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return + * an instantiated object. + * + * E.g.: @NonNull @Required private String title; + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FIELD) +annotation class Required diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java deleted file mode 100644 index 8ea2fa1ed..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package fr.free.nrw.commons.wikidata.model; - -import java.util.List; - -/** - * Model class for API response obtained from search for depictions - */ -public class DepictSearchResponse { - private final List search; - - /** - * Constructor to initialise value of the search object - */ - public DepictSearchResponse(List search) { - this.search = search; - } - - /** - * @return List for the DepictSearchResponse - */ - public List getSearch() { - return search; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt new file mode 100644 index 000000000..5a0ed8c49 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.wikidata.model + +/** + * Model class for API response obtained from search for depictions + */ +class DepictSearchResponse( + /** + * @return List for the DepictSearchResponse + + */ + val search: List +) diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java deleted file mode 100644 index 9dab836cf..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java +++ /dev/null @@ -1,106 +0,0 @@ -package fr.free.nrw.commons.wikidata.model; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.gson.annotations.SerializedName; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import fr.free.nrw.commons.wikidata.mwapi.MwResponse; - - -public class Entities extends MwResponse { - @Nullable private Map entities; - private int success; - - @NotNull - public Map entities() { - return entities != null ? entities : Collections.emptyMap(); - } - - public int getSuccess() { - return success; - } - - @Nullable public Entity getFirst() { - if (entities == null) { - return null; - } - return entities.values().iterator().next(); - } - - @Override - public void postProcess() { - if (getFirst() != null && getFirst().isMissing()) { - throw new RuntimeException("The requested entity was not found."); - } - } - - public static class Entity { - @Nullable private String type; - @Nullable private String id; - @Nullable private Map labels; - @Nullable private Map descriptions; - @Nullable private Map sitelinks; - @Nullable @SerializedName(value = "statements", alternate = "claims") private Map> statements; - @Nullable private String missing; - - @NonNull public String id() { - return StringUtils.defaultString(id); - } - - @NonNull public Map labels() { - return labels != null ? labels : Collections.emptyMap(); - } - - @NonNull public Map descriptions() { - return descriptions != null ? descriptions : Collections.emptyMap(); - } - - @NonNull public Map sitelinks() { - return sitelinks != null ? sitelinks : Collections.emptyMap(); - } - - @Nullable - public Map> getStatements() { - return statements; - } - - boolean isMissing() { - return "-1".equals(id) && missing != null; - } - } - - public static class Label { - @Nullable private String language; - @Nullable private String value; - - public Label(@Nullable final String language, @Nullable final String value) { - this.language = language; - this.value = value; - } - - @NonNull public String language() { - return StringUtils.defaultString(language); - } - - @NonNull public String value() { - return StringUtils.defaultString(value); - } - } - - public static class SiteLink { - @Nullable private String site; - @Nullable private String title; - - @NonNull public String getSite() { - return StringUtils.defaultString(site); - } - - @NonNull public String getTitle() { - return StringUtils.defaultString(title); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt new file mode 100644 index 000000000..588dbd262 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt @@ -0,0 +1,64 @@ +package fr.free.nrw.commons.wikidata.model + +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.mwapi.MwResponse +import org.apache.commons.lang3.StringUtils + +class Entities : MwResponse() { + private val entities: Map? = null + val success: Int = 0 + + fun entities(): Map = entities ?: emptyMap() + + private val first : Entity? + get() = entities?.values?.iterator()?.next() + + override fun postProcess() { + first?.let { + if (it.isMissing()) throw RuntimeException("The requested entity was not found.") + } + } + + class Entity { + private val type: String? = null + private val id: String? = null + private val labels: Map? = null + private val descriptions: Map? = null + private val sitelinks: Map? = null + + @SerializedName(value = "statements", alternate = ["claims"]) + val statements: Map>? = null + private val missing: String? = null + + fun id(): String = + StringUtils.defaultString(id) + + fun labels(): Map = + labels ?: emptyMap() + + fun descriptions(): Map = + descriptions ?: emptyMap() + + fun sitelinks(): Map = + sitelinks ?: emptyMap() + + fun isMissing(): Boolean = + "-1" == id && missing != null + } + + class Label(private val language: String?, private val value: String?) { + fun language(): String = + StringUtils.defaultString(language) + + fun value(): String = + StringUtils.defaultString(value) + } + + class SiteLink { + val site: String? = null + get() = StringUtils.defaultString(field) + + private val title: String? = null + get() = StringUtils.defaultString(field) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java deleted file mode 100644 index 204ea0ab4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java +++ /dev/null @@ -1,292 +0,0 @@ -package fr.free.nrw.commons.wikidata.model; - -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextUtils; - -import androidx.annotation.NonNull; - -import com.google.gson.annotations.SerializedName; - -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.language.AppLanguageLookUpTable; - -/** - * The base URL and Wikipedia language code for a MediaWiki site. Examples: - * - *
    - * Name: scheme / authority / language code - *
  • English Wikipedia: HTTPS / en.wikipedia.org / en
  • - *
  • Chinese Wikipedia: HTTPS / zh.wikipedia.org / zh-hans or zh-hant
  • - *
  • Meta-Wiki: HTTPS / meta.wikimedia.org / (none)
  • - *
  • Test Wikipedia: HTTPS / test.wikipedia.org / test
  • - *
  • Võro Wikipedia: HTTPS / fiu-vro.wikipedia.org / fiu-vro
  • - *
  • Simple English Wikipedia: HTTPS / simple.wikipedia.org / simple
  • - *
  • Simple English Wikipedia (beta cluster mirror): HTTP / simple.wikipedia.beta.wmflabs.org / simple
  • - *
  • Development: HTTP / 192.168.1.11:8080 / (none)
  • - *
- * - * As shown above, the language code or mapping is part of the authority: - *
    - * Validity: authority / language code - *
  • Correct: "test.wikipedia.org" / "test"
  • - *
  • Correct: "wikipedia.org", ""
  • - *
  • Correct: "no.wikipedia.org", "nb"
  • - *
  • Incorrect: "wikipedia.org", "test"
  • - *
- */ -public class WikiSite implements Parcelable { - private static String WIKIPEDIA_URL = "https://wikipedia.org/"; - - public static final String DEFAULT_SCHEME = "https"; - private static String DEFAULT_BASE_URL = WIKIPEDIA_URL; - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - @Override - public WikiSite createFromParcel(Parcel in) { - return new WikiSite(in); - } - - @Override - public WikiSite[] newArray(int size) { - return new WikiSite[size]; - } - }; - - // todo: remove @SerializedName. this is now in the TypeAdapter and a "uri" case may be added - @SerializedName("domain") @NonNull private final Uri uri; - @NonNull private String languageCode; - - public static WikiSite forLanguageCode(@NonNull String languageCode) { - Uri uri = ensureScheme(Uri.parse(DEFAULT_BASE_URL)); - return new WikiSite((languageCode.isEmpty() - ? "" : (languageCodeToSubdomain(languageCode) + ".")) + uri.getAuthority(), - languageCode); - } - - public WikiSite(@NonNull Uri uri) { - Uri tempUri = ensureScheme(uri); - String authority = tempUri.getAuthority(); - if (("wikipedia.org".equals(authority) || "www.wikipedia.org".equals(authority)) - && tempUri.getPath() != null && tempUri.getPath().startsWith("/wiki")) { - // Special case for Wikipedia only: assume English subdomain when none given. - authority = "en.wikipedia.org"; - } - String langVariant = getLanguageVariantFromUri(tempUri); - if (!TextUtils.isEmpty(langVariant)) { - languageCode = langVariant; - } else { - languageCode = authorityToLanguageCode(authority); - } - this.uri = new Uri.Builder() - .scheme(tempUri.getScheme()) - .encodedAuthority(authority) - .build(); - } - - /** Get language variant code from a Uri, e.g. "zh-*", otherwise returns empty string. */ - @NonNull - private String getLanguageVariantFromUri(@NonNull Uri uri) { - if (TextUtils.isEmpty(uri.getPath())) { - return ""; - } - String[] parts = StringUtils.split(StringUtils.defaultString(uri.getPath()), '/'); - return parts.length > 1 && !parts[0].equals("wiki") ? parts[0] : ""; - } - - public WikiSite(@NonNull String url) { - this(url.startsWith("http") ? Uri.parse(url) : url.startsWith("//") - ? Uri.parse(DEFAULT_SCHEME + ":" + url) : Uri.parse(DEFAULT_SCHEME + "://" + url)); - } - - public WikiSite(@NonNull String authority, @NonNull String languageCode) { - this(authority); - this.languageCode = languageCode; - } - - @NonNull - public String scheme() { - return TextUtils.isEmpty(uri.getScheme()) ? DEFAULT_SCHEME : uri.getScheme(); - } - - /** - * @return The complete wiki authority including language subdomain but not including scheme, - * authentication, port, nor trailing slash. - * - * @see URL syntax - */ - @NonNull - public String authority() { - return uri.getAuthority(); - } - - /** - * Like {@link #authority()} but with a "m." between the language subdomain and the rest of the host. - * Examples: - * - *
    - *
  • English Wikipedia: en.m.wikipedia.org
  • - *
  • Chinese Wikipedia: zh.m.wikipedia.org
  • - *
  • Meta-Wiki: meta.m.wikimedia.org
  • - *
  • Test Wikipedia: test.m.wikipedia.org
  • - *
  • Võro Wikipedia: fiu-vro.m.wikipedia.org
  • - *
  • Simple English Wikipedia: simple.m.wikipedia.org
  • - *
  • Simple English Wikipedia (beta cluster mirror): simple.m.wikipedia.beta.wmflabs.org
  • - *
  • Development: m.192.168.1.11
  • - *
- */ - @NonNull - public String mobileAuthority() { - return authorityToMobile(authority()); - } - - /** - * Get wiki's mobile URL - * Eg. https://en.m.wikipedia.org - * @return - */ - public String mobileUrl() { - return String.format("%1$s://%2$s", scheme(), mobileAuthority()); - } - - @NonNull - public String subdomain() { - return languageCodeToSubdomain(languageCode); - } - - /** - * @return A path without an authority for the segment including a leading "/". - */ - @NonNull - public String path(@NonNull String segment) { - return "/w/" + segment; - } - - - @NonNull public Uri uri() { - return uri; - } - - /** - * @return The canonical URL. e.g., https://en.wikipedia.org. - */ - @NonNull public String url() { - return uri.toString(); - } - - /** - * @return The canonical URL for segment. e.g., https://en.wikipedia.org/w/foo. - */ - @NonNull public String url(@NonNull String segment) { - return url() + path(segment); - } - - /** - * @return The wiki language code which may differ from the language subdomain. Empty if - * language code is unknown. Ex: "en", "zh-hans", "" - * - * @see AppLanguageLookUpTable - */ - @NonNull - public String languageCode() { - return languageCode; - } - - // Auto-generated - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - - WikiSite wiki = (WikiSite) o; - - if (!uri.equals(wiki.uri)) { - return false; - } - return languageCode.equals(wiki.languageCode); - } - - // Auto-generated - @Override - public int hashCode() { - int result = uri.hashCode(); - result = 31 * result + languageCode.hashCode(); - return result; - } - - // Auto-generated - @Override - public String toString() { - return "WikiSite{" - + "uri=" + uri - + ", languageCode='" + languageCode + '\'' - + '}'; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(@NonNull Parcel dest, int flags) { - dest.writeParcelable(uri, 0); - dest.writeString(languageCode); - } - - protected WikiSite(@NonNull Parcel in) { - this.uri = in.readParcelable(Uri.class.getClassLoader()); - this.languageCode = in.readString(); - } - - @NonNull - private static String languageCodeToSubdomain(@NonNull String languageCode) { - switch (languageCode) { - case AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE: - case AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_CN_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_HK_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_MO_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_SG_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_TW_LANGUAGE_CODE: - return AppLanguageLookUpTable.CHINESE_LANGUAGE_CODE; - case AppLanguageLookUpTable.NORWEGIAN_BOKMAL_LANGUAGE_CODE: - return AppLanguageLookUpTable.NORWEGIAN_LEGACY_LANGUAGE_CODE; // T114042 - default: - return languageCode; - } - } - - @NonNull private static String authorityToLanguageCode(@NonNull String authority) { - String[] parts = authority.split("\\."); - final int minLengthForSubdomain = 3; - if (parts.length < minLengthForSubdomain - || parts.length == minLengthForSubdomain && parts[0].equals("m")) { - // "" - // wikipedia.org - // m.wikipedia.org - return ""; - } - return parts[0]; - } - - @NonNull private static Uri ensureScheme(@NonNull Uri uri) { - if (TextUtils.isEmpty(uri.getScheme())) { - return uri.buildUpon().scheme(DEFAULT_SCHEME).build(); - } - return uri; - } - - /** @param authority Host and optional port. */ - @NonNull private String authorityToMobile(@NonNull String authority) { - if (authority.startsWith("m.") || authority.contains(".m.")) { - return authority; - } - return authority.replaceFirst("^" + subdomain() + "\\.?", "$0m."); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt new file mode 100644 index 000000000..1cd0bb858 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt @@ -0,0 +1,269 @@ +package fr.free.nrw.commons.wikidata.model + +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import android.text.TextUtils +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.language.AppLanguageLookUpTable +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_CN_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_HK_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_MO_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_SG_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_TW_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.NORWEGIAN_BOKMAL_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.NORWEGIAN_LEGACY_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.SIMPLIFIED_CHINESE_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.TRADITIONAL_CHINESE_LANGUAGE_CODE +import org.apache.commons.lang3.StringUtils +import java.util.Locale + +/** + * The base URL and Wikipedia language code for a MediaWiki site. Examples: + * + * + * Name: scheme / authority / language code + * * English Wikipedia: HTTPS / en.wikipedia.org / en + * * Chinese Wikipedia: HTTPS / zh.wikipedia.org / zh-hans or zh-hant + * * Meta-Wiki: HTTPS / meta.wikimedia.org / (none) + * * Test Wikipedia: HTTPS / test.wikipedia.org / test + * * Võro Wikipedia: HTTPS / fiu-vro.wikipedia.org / fiu-vro + * * Simple English Wikipedia: HTTPS / simple.wikipedia.org / simple + * * Simple English Wikipedia (beta cluster mirror): HTTP / simple.wikipedia.beta.wmflabs.org / simple + * * Development: HTTP / 192.168.1.11:8080 / (none) + * + * + * **As shown above, the language code or mapping is part of the authority:** + * + * Validity: authority / language code + * * Correct: "test.wikipedia.org" / "test" + * * Correct: "wikipedia.org", "" + * * Correct: "no.wikipedia.org", "nb" + * * Incorrect: "wikipedia.org", "test" + * + */ +class WikiSite : Parcelable { + //TODO: remove @SerializedName. this is now in the TypeAdapter and a "uri" case may be added + @SerializedName("domain") + private val uri: Uri + + private var languageCode: String? = null + + constructor(uri: Uri) { + val tempUri = ensureScheme(uri) + var authority = tempUri.authority + + if (authority.isWikipedia && tempUri.path?.startsWith("/wiki") == true) { + // Special case for Wikipedia only: assume English subdomain when none given. + authority = "en.wikipedia.org" + } + + val langVariant = getLanguageVariantFromUri(tempUri) + languageCode = if (!TextUtils.isEmpty(langVariant)) { + langVariant + } else { + authorityToLanguageCode(authority!!) + } + + this.uri = Uri.Builder() + .scheme(tempUri.scheme) + .encodedAuthority(authority) + .build() + } + + private val String?.isWikipedia: Boolean get() = + (this == "wikipedia.org" || this == "www.wikipedia.org") + + /** Get language variant code from a Uri, e.g. "zh-*", otherwise returns empty string. */ + private fun getLanguageVariantFromUri(uri: Uri): String { + if (TextUtils.isEmpty(uri.path)) { + return "" + } + val parts = StringUtils.split(StringUtils.defaultString(uri.path), '/') + return if (parts.size > 1 && parts[0] != "wiki") parts[0] else "" + } + + constructor(url: String) : this( + if (url.startsWith("http")) Uri.parse(url) else if (url.startsWith("//")) + Uri.parse("$DEFAULT_SCHEME:$url") + else + Uri.parse("$DEFAULT_SCHEME://$url") + ) + + constructor(authority: String, languageCode: String) : this(authority) { + this.languageCode = languageCode + } + + fun scheme(): String = + if (TextUtils.isEmpty(uri.scheme)) DEFAULT_SCHEME else uri.scheme!! + + /** + * @return The complete wiki authority including language subdomain but not including scheme, + * authentication, port, nor trailing slash. + * + * @see [URL syntax](https://en.wikipedia.org/wiki/Uniform_Resource_Locator.Syntax) + */ + fun authority(): String = uri.authority!! + + /** + * Like [.authority] but with a "m." between the language subdomain and the rest of the host. + * Examples: + * + * + * * English Wikipedia: en.m.wikipedia.org + * * Chinese Wikipedia: zh.m.wikipedia.org + * * Meta-Wiki: meta.m.wikimedia.org + * * Test Wikipedia: test.m.wikipedia.org + * * Võro Wikipedia: fiu-vro.m.wikipedia.org + * * Simple English Wikipedia: simple.m.wikipedia.org + * * Simple English Wikipedia (beta cluster mirror): simple.m.wikipedia.beta.wmflabs.org + * * Development: m.192.168.1.11 + * + */ + fun mobileAuthority(): String = authorityToMobile(authority()) + + /** + * Get wiki's mobile URL + * Eg. https://en.m.wikipedia.org + * @return + */ + fun mobileUrl(): String = String.format("%1\$s://%2\$s", scheme(), mobileAuthority()) + + fun subdomain(): String = languageCodeToSubdomain(languageCode!!) + + /** + * @return A path without an authority for the segment including a leading "/". + */ + fun path(segment: String): String = "/w/$segment" + + + fun uri(): Uri = uri + + /** + * @return The canonical URL. e.g., https://en.wikipedia.org. + */ + fun url(): String = uri.toString() + + /** + * @return The canonical URL for segment. e.g., https://en.wikipedia.org/w/foo. + */ + fun url(segment: String): String = url() + path(segment) + + /** + * @return The wiki language code which may differ from the language subdomain. Empty if + * language code is unknown. Ex: "en", "zh-hans", "" + * + * @see AppLanguageLookUpTable + */ + fun languageCode(): String = languageCode!! + + // Auto-generated + override fun equals(o: Any?): Boolean { + if (this === o) { + return true + } + if (o == null || javaClass != o.javaClass) { + return false + } + + val wiki = o as WikiSite + + if (uri != wiki.uri) { + return false + } + return languageCode == wiki.languageCode + } + + // Auto-generated + override fun hashCode(): Int { + var result = uri.hashCode() + result = 31 * result + languageCode.hashCode() + return result + } + + // Auto-generated + override fun toString(): String { + return ("WikiSite{" + + "uri=" + uri + + ", languageCode='" + languageCode + '\'' + + '}') + } + + override fun describeContents(): Int = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(uri, 0) + dest.writeString(languageCode) + } + + protected constructor(`in`: Parcel) { + uri = `in`.readParcelable(Uri::class.java.classLoader)!! + languageCode = `in`.readString() + } + + /** @param authority Host and optional port. + */ + private fun authorityToMobile(authority: String): String { + if (authority.startsWith("m.") || authority.contains(".m.")) { + return authority + } + return authority.replaceFirst(("^" + subdomain() + "\\.?").toRegex(), "$0m.") + } + + companion object { + const val WIKIPEDIA_URL = "https://wikipedia.org/" + const val DEFAULT_SCHEME: String = "https" + + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): WikiSite { + return WikiSite(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + fun forDefaultLocaleLanguageCode(): WikiSite { + val languageCode: String = Locale.getDefault().language + val subdomain = if (languageCode.isEmpty()) "" else languageCodeToSubdomain(languageCode) + "." + val uri = ensureScheme(Uri.parse(WIKIPEDIA_URL)) + return WikiSite(subdomain + uri.authority, languageCode) + } + + private fun languageCodeToSubdomain(languageCode: String): String = when (languageCode) { + SIMPLIFIED_CHINESE_LANGUAGE_CODE, + TRADITIONAL_CHINESE_LANGUAGE_CODE, + CHINESE_CN_LANGUAGE_CODE, + CHINESE_HK_LANGUAGE_CODE, + CHINESE_MO_LANGUAGE_CODE, + CHINESE_SG_LANGUAGE_CODE, + CHINESE_TW_LANGUAGE_CODE -> CHINESE_LANGUAGE_CODE + + NORWEGIAN_BOKMAL_LANGUAGE_CODE -> NORWEGIAN_LEGACY_LANGUAGE_CODE // T114042 + + else -> languageCode + } + + private fun authorityToLanguageCode(authority: String): String { + val parts = authority.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val minLengthForSubdomain = 3 + if (parts.size < minLengthForSubdomain || parts.size == minLengthForSubdomain && parts[0] == "m") { + // "" + // wikipedia.org + // m.wikipedia.org + return "" + } + return parts[0] + } + + private fun ensureScheme(uri: Uri): Uri { + if (TextUtils.isEmpty(uri.scheme)) { + return uri.buildUpon().scheme(DEFAULT_SCHEME).build() + } + return uri + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.java deleted file mode 100644 index b79612ecc..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.java +++ /dev/null @@ -1,36 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.edit; - -import androidx.annotation.Nullable; -import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse; - -public class Edit extends MwPostResponse { - @Nullable private Result edit; - - @Nullable public Result edit() { - return edit; - } - - public class Result { - @Nullable private String result; - @Nullable private String code; - @Nullable private String info; - @Nullable private String warning; - - public boolean editSucceeded() { - return "Success".equals(result); - } - - @Nullable public String code() { - return code; - } - - @Nullable public String info() { - return info; - } - - @Nullable public String warning() { - return warning; - } - - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.kt new file mode 100644 index 000000000..897b057bd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/Edit.kt @@ -0,0 +1,25 @@ +package fr.free.nrw.commons.wikidata.model.edit + +import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse + +class Edit : MwPostResponse() { + private val edit: Result? = null + + fun edit(): Result? = edit + + class Result { + private val result: String? = null + private val code: String? = null + private val info: String? = null + private val warning: String? = null + + fun editSucceeded(): Boolean = + "Success" == result + + fun code(): String? = code + + fun info(): String? = info + + fun warning(): String? = warning + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/EditResult.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/EditResult.java deleted file mode 100644 index 1733f374e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/edit/EditResult.java +++ /dev/null @@ -1,31 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.edit; - -import android.os.Parcel; -import android.os.Parcelable; -import fr.free.nrw.commons.wikidata.model.BaseModel; - -public abstract class EditResult extends BaseModel implements Parcelable { - private final String result; - - public EditResult(String result) { - this.result = result; - } - - protected EditResult(Parcel in) { - this.result = in.readString(); - } - - public String getResult() { - return result; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(result); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.java deleted file mode 100644 index 2bd63400f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.java +++ /dev/null @@ -1,102 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.gallery; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import org.apache.commons.lang3.StringUtils; - - -public class ExtMetadata { - @SerializedName("DateTime") @Nullable private Values dateTime; - @SerializedName("ObjectName") @Nullable private Values objectName; - @SerializedName("CommonsMetadataExtension") @Nullable private Values commonsMetadataExtension; - @SerializedName("Categories") @Nullable private Values categories; - @SerializedName("Assessments") @Nullable private Values assessments; - @SerializedName("GPSLatitude") @Nullable private Values gpsLatitude; - @SerializedName("GPSLongitude") @Nullable private Values gpsLongitude; - @SerializedName("ImageDescription") @Nullable private Values imageDescription; - @SerializedName("DateTimeOriginal") @Nullable private Values dateTimeOriginal; - @SerializedName("Artist") @Nullable private Values artist; - @SerializedName("Credit") @Nullable private Values credit; - @SerializedName("Permission") @Nullable private Values permission; - @SerializedName("AuthorCount") @Nullable private Values authorCount; - @SerializedName("LicenseShortName") @Nullable private Values licenseShortName; - @SerializedName("UsageTerms") @Nullable private Values usageTerms; - @SerializedName("LicenseUrl") @Nullable private Values licenseUrl; - @SerializedName("AttributionRequired") @Nullable private Values attributionRequired; - @SerializedName("Copyrighted") @Nullable private Values copyrighted; - @SerializedName("Restrictions") @Nullable private Values restrictions; - @SerializedName("License") @Nullable private Values license; - - @NonNull public String licenseShortName() { - return StringUtils.defaultString(licenseShortName == null ? null : licenseShortName.value()); - } - - @NonNull public String licenseUrl() { - return StringUtils.defaultString(licenseUrl == null ? null : licenseUrl.value()); - } - - @NonNull public String license() { - return StringUtils.defaultString(license == null ? null : license.value()); - } - - @NonNull public String imageDescription() { - return StringUtils.defaultString(imageDescription == null ? null : imageDescription.value()); - } - - @NonNull public String imageDescriptionSource() { - return StringUtils.defaultString(imageDescription == null ? null : imageDescription.source()); - } - - @NonNull public String objectName() { - return StringUtils.defaultString(objectName == null ? null : objectName.value()); - } - - @NonNull public String usageTerms() { - return StringUtils.defaultString(usageTerms == null ? null : usageTerms.value()); - } - - @NonNull public String dateTimeOriginal() { - return StringUtils.defaultString(dateTimeOriginal == null ? null : dateTimeOriginal.value()); - } - - @NonNull public String dateTime() { - return StringUtils.defaultString(dateTime == null ? null : dateTime.value()); - } - - @NonNull public String artist() { - return StringUtils.defaultString(artist == null ? null : artist.value()); - } - - @NonNull public String getCategories() { - return StringUtils.defaultString(categories == null ? null : categories.value()); - } - - @NonNull public String getGpsLatitude() { - return StringUtils.defaultString(gpsLatitude == null ? null : gpsLatitude.value()); - } - - @NonNull public String getGpsLongitude() { - return StringUtils.defaultString(gpsLongitude == null ? null : gpsLongitude.value()); - } - - @NonNull public String credit() { - return StringUtils.defaultString(credit == null ? null : credit.value()); - } - - public class Values { - @Nullable private String value; - @Nullable private String source; - @Nullable private String hidden; - - @Nullable public String value() { - return value; - } - - @Nullable public String source() { - return source; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.kt new file mode 100644 index 000000000..63c018252 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ExtMetadata.kt @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.wikidata.model.gallery + +import com.google.gson.annotations.SerializedName +import org.apache.commons.lang3.StringUtils + +class ExtMetadata { + @SerializedName("DateTime") private val dateTime: Values? = null + @SerializedName("ObjectName") private val objectName: Values? = null + @SerializedName("CommonsMetadataExtension") private val commonsMetadataExtension: Values? = null + @SerializedName("Categories") private val categories: Values? = null + @SerializedName("Assessments") private val assessments: Values? = null + @SerializedName("GPSLatitude") private val gpsLatitude: Values? = null + @SerializedName("GPSLongitude") private val gpsLongitude: Values? = null + @SerializedName("ImageDescription") private val imageDescription: Values? = null + @SerializedName("DateTimeOriginal") private val dateTimeOriginal: Values? = null + @SerializedName("Artist") private val artist: Values? = null + @SerializedName("Credit") private val credit: Values? = null + @SerializedName("Permission") private val permission: Values? = null + @SerializedName("AuthorCount") private val authorCount: Values? = null + @SerializedName("LicenseShortName") private val licenseShortName: Values? = null + @SerializedName("UsageTerms") private val usageTerms: Values? = null + @SerializedName("LicenseUrl") private val licenseUrl: Values? = null + @SerializedName("AttributionRequired") private val attributionRequired: Values? = null + @SerializedName("Copyrighted") private val copyrighted: Values? = null + @SerializedName("Restrictions") private val restrictions: Values? = null + @SerializedName("License") private val license: Values? = null + + fun licenseShortName(): String = licenseShortName?.value ?: "" + + fun licenseUrl(): String = licenseUrl?.value ?: "" + + fun license(): String = license?.value ?: "" + + fun imageDescription(): String = imageDescription?.value ?: "" + + fun imageDescriptionSource(): String = imageDescription?.source ?: "" + + fun objectName(): String = objectName?.value ?: "" + + fun usageTerms(): String = usageTerms?.value ?: "" + + fun dateTimeOriginal(): String = dateTimeOriginal?.value ?: "" + + fun dateTime(): String = dateTime?.value ?: "" + + fun artist(): String = artist?.value ?: "" + + fun categories(): String = categories?.value ?: "" + + fun gpsLatitude(): String = gpsLatitude?.value ?: "" + + fun gpsLongitude(): String = gpsLongitude?.value ?: "" + + fun credit(): String = credit?.value ?: "" + + class Values { + val value: String? = null + val source: String? = null + val hidden: String? = null + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.java deleted file mode 100644 index 2e1349ae9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.java +++ /dev/null @@ -1,121 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.gallery; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import org.apache.commons.lang3.StringUtils; - -import java.io.Serializable; - -/** - * Gson POJO for a standard image info object as returned by the API ImageInfo module - */ - -public class ImageInfo implements Serializable { - private int size; - private int width; - private int height; - @Nullable private String source; - @SerializedName("thumburl") @Nullable private String thumbUrl; - @SerializedName("thumbwidth") private int thumbWidth; - @SerializedName("thumbheight") private int thumbHeight; - @SerializedName("url") @Nullable private String originalUrl; - @SerializedName("descriptionurl") @Nullable private String descriptionUrl; - @SerializedName("descriptionshorturl") @Nullable private String descriptionShortUrl; - @SerializedName("mime") @Nullable private String mimeType; - @SerializedName("extmetadata")@Nullable private ExtMetadata metadata; - @Nullable private String user; - @Nullable private String timestamp; - - /** - * Query width, default width parameter of the API query in pixels. - */ - final private static int QUERY_WIDTH = 640; - - /** - * Threshold height, the minimum height of the image in pixels. - */ - final private static int THRESHOLD_HEIGHT = 220; - - @NonNull - public String getSource() { - return StringUtils.defaultString(source); - } - - public void setSource(@Nullable String source) { - this.source = source; - } - - public int getSize() { - return size; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - - /** - * Get the thumbnail width. - * @return - */ - public int getThumbWidth() { return thumbWidth; } - - /** - * Get the thumbnail height. - * @return - */ - public int getThumbHeight() { return thumbHeight; } - - @NonNull public String getMimeType() { - return StringUtils.defaultString(mimeType, "*/*"); - } - - @NonNull public String getThumbUrl() { - updateThumbUrl(); - return StringUtils.defaultString(thumbUrl); - } - - @NonNull public String getOriginalUrl() { - return StringUtils.defaultString(originalUrl); - } - - @NonNull public String getUser() { - return StringUtils.defaultString(user); - } - - @NonNull public String getTimestamp() { - return StringUtils.defaultString(timestamp); - } - - @Nullable public ExtMetadata getMetadata() { - return metadata; - } - - /** - * Updates the ThumbUrl if image dimensions are not sufficient. - * Specifically, in panoramic images the height retrieved is less than required due to large width to height ratio, - * so we update the thumb url keeping a minimum height threshold. - */ - private void updateThumbUrl() { - // If thumbHeight retrieved from API is less than THRESHOLD_HEIGHT - if(getThumbHeight() < THRESHOLD_HEIGHT){ - // If thumbWidthRetrieved is same as queried width ( If not tells us that the image has no larger dimensions. ) - if(getThumbWidth() == QUERY_WIDTH){ - // Calculate new width depending on the aspect ratio. - final int finalWidth = (int)(THRESHOLD_HEIGHT * getThumbWidth() * 1.0 / getThumbHeight()); - thumbHeight = THRESHOLD_HEIGHT; - thumbWidth = finalWidth; - final String toReplace = "/" + QUERY_WIDTH + "px"; - final int position = thumbUrl.lastIndexOf(toReplace); - thumbUrl = (new StringBuilder(thumbUrl)).replace(position, position + toReplace.length(), "/" + thumbWidth + "px").toString(); - } - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.kt new file mode 100644 index 000000000..492e2e1f8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/ImageInfo.kt @@ -0,0 +1,129 @@ +package fr.free.nrw.commons.wikidata.model.gallery + +import com.google.gson.annotations.SerializedName +import org.apache.commons.lang3.StringUtils +import java.io.Serializable + +/** + * Gson POJO for a standard image info object as returned by the API ImageInfo module + */ +open class ImageInfo : Serializable { + private val size = 0 + private val width = 0 + private val height = 0 + private var source: String? = null + + @SerializedName("thumburl") + private var thumbUrl: String? = null + + @SerializedName("thumbwidth") + private var thumbWidth = 0 + + @SerializedName("thumbheight") + private var thumbHeight = 0 + + @SerializedName("url") + private val originalUrl: String? = null + + @SerializedName("descriptionurl") + private val descriptionUrl: String? = null + + @SerializedName("descriptionshorturl") + private val descriptionShortUrl: String? = null + + @SerializedName("mime") + private val mimeType: String? = null + + @SerializedName("extmetadata") + private val metadata: ExtMetadata? = null + private val user: String? = null + private val timestamp: String? = null + + fun getSource(): String { + return source ?: "" + } + + fun setSource(source: String?) { + this.source = source + } + + fun getSize(): Int { + return size + } + + fun getWidth(): Int { + return width + } + + fun getHeight(): Int { + return height + } + + fun getThumbWidth(): Int { + return thumbWidth + } + + fun getThumbHeight(): Int { + return thumbHeight + } + + fun getMimeType(): String { + return mimeType ?: "*/*" + } + + fun getThumbUrl(): String { + updateThumbUrl() + return thumbUrl ?: "" + } + + fun getOriginalUrl(): String { + return originalUrl ?: "" + } + + fun getUser(): String { + return user ?: "" + } + + fun getTimestamp(): String { + return timestamp ?: "" + } + + fun getMetadata(): ExtMetadata? = metadata + + /** + * Updates the ThumbUrl if image dimensions are not sufficient. Specifically, in panoramic + * images the height retrieved is less than required due to large width to height ratio, so we + * update the thumb url keeping a minimum height threshold. + */ + private fun updateThumbUrl() { + // If thumbHeight retrieved from API is less than THRESHOLD_HEIGHT + if (getThumbHeight() < THRESHOLD_HEIGHT) { + // If thumbWidthRetrieved is same as queried width ( If not tells us that the image has no larger dimensions. ) + if (getThumbWidth() == QUERY_WIDTH) { + // Calculate new width depending on the aspect ratio. + val finalWidth = (THRESHOLD_HEIGHT * getThumbWidth() * 1.0 + / getThumbHeight()).toInt() + thumbHeight = THRESHOLD_HEIGHT + thumbWidth = finalWidth + val toReplace = "/" + QUERY_WIDTH + "px" + val position = thumbUrl!!.lastIndexOf(toReplace) + thumbUrl = (StringBuilder(thumbUrl ?: "")).replace( + position, + position + toReplace.length, "/" + thumbWidth + "px" + ).toString() + } + } + } + + companion object { + /** + * Query width, default width parameter of the API query in pixels. + */ + private const val QUERY_WIDTH = 640 + + /** + * Threshold height, the minimum height of the image in pixels. + */ + private const val THRESHOLD_HEIGHT = 220 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/VideoInfo.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/VideoInfo.java deleted file mode 100644 index 32388d5cf..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/gallery/VideoInfo.java +++ /dev/null @@ -1,16 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.gallery; - -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import java.util.List; - -/** - * Gson POJO for a standard video info object as returned by the API VideoInfo module - */ -public class VideoInfo extends ImageInfo { - @Nullable private List codecs; - @SuppressWarnings("unused,NullableProblems") @Nullable private String name; - @SuppressWarnings("unused,NullableProblems") @Nullable @SerializedName("short_name") private String shortName; -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java deleted file mode 100644 index 2b18669a4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java +++ /dev/null @@ -1,190 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.notifications; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.annotations.SerializedName; - -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.utils.DateUtil; -import fr.free.nrw.commons.wikidata.GsonUtil; - -import java.text.ParseException; -import java.util.Date; -import timber.log.Timber; - -public class Notification { - @Nullable private String wiki; - private long id; - @Nullable private String type; - @Nullable private String category; - - @Nullable private Title title; - @Nullable private Timestamp timestamp; - @SerializedName("*") @Nullable private Contents contents; - - @NonNull public String wiki() { - return StringUtils.defaultString(wiki); - } - - public long id() { - return id; - } - - public void setId(final long id) { - this.id = id; - } - - public long key() { - return id + wiki().hashCode(); - } - - @NonNull public String type() { - return StringUtils.defaultString(type); - } - - @Nullable public Title title() { - return title; - } - - @Nullable public Contents getContents() { - return contents; - } - - public void setContents(@Nullable final Contents contents) { - this.contents = contents; - } - - @NonNull public Date getTimestamp() { - return timestamp != null ? timestamp.date() : new Date(); - } - - public void setTimestamp(@Nullable final Timestamp timestamp) { - this.timestamp = timestamp; - } - - @NonNull String getUtcIso8601() { - return StringUtils.defaultString(timestamp != null ? timestamp.utciso8601 : null); - } - - public boolean isFromWikidata() { - return wiki().equals("wikidatawiki"); - } - - @Override public String toString() { - return Long.toString(id); - } - - public static class Title { - @Nullable private String full; - @Nullable private String text; - - @NonNull public String text() { - return StringUtils.defaultString(text); - } - - @NonNull public String full() { - return StringUtils.defaultString(full); - } - } - - public static class Timestamp { - @Nullable private String utciso8601; - - public void setUtciso8601(@Nullable final String utciso8601) { - this.utciso8601 = utciso8601; - } - - public Date date() { - try { - return DateUtil.iso8601DateParse(utciso8601); - } catch (ParseException e) { - Timber.e(e); - return new Date(); - } - } - } - - public static class Link { - @Nullable private String url; - @Nullable private String label; - @Nullable private String tooltip; - @Nullable private String description; - @Nullable private String icon; - - @NonNull public String getUrl() { - return StringUtils.defaultString(url); - } - - public void setUrl(@Nullable final String url) { - this.url = url; - } - - @NonNull public String getTooltip() { - return StringUtils.defaultString(tooltip); - } - - @NonNull public String getLabel() { - return StringUtils.defaultString(label); - } - - @NonNull public String getIcon() { - return StringUtils.defaultString(icon); - } - } - - public static class Links { - @Nullable private JsonElement primary; - private Link primaryLink; - - public void setPrimary(@Nullable final JsonElement primary) { - this.primary = primary; - } - - @Nullable public Link getPrimary() { - if (primary == null) { - return null; - } - if (primaryLink == null && primary instanceof JsonObject) { - primaryLink = GsonUtil.getDefaultGson().fromJson(primary, Link.class); - } - return primaryLink; - } - - } - - public static class Contents { - @Nullable private String header; - @Nullable private String compactHeader; - @Nullable private String body; - @Nullable private String icon; - @Nullable private Links links; - - @NonNull public String getHeader() { - return StringUtils.defaultString(header); - } - - @NonNull public String getCompactHeader() { - return StringUtils.defaultString(compactHeader); - } - - public void setCompactHeader(@Nullable final String compactHeader) { - this.compactHeader = compactHeader; - } - - @NonNull public String getBody() { - return StringUtils.defaultString(body); - } - - @Nullable public Links getLinks() { - return links; - } - - public void setLinks(@Nullable final Links links) { - this.links = links; - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.kt new file mode 100644 index 000000000..dd73d9723 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.kt @@ -0,0 +1,124 @@ +package fr.free.nrw.commons.wikidata.model.notifications + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.utils.DateUtil.iso8601DateParse +import fr.free.nrw.commons.wikidata.GsonUtil.defaultGson +import org.apache.commons.lang3.StringUtils +import timber.log.Timber +import java.text.ParseException +import java.util.Date + +class Notification { + private val wiki: String? = null + private var id: Long = 0 + private val type: String? = null + private val category: String? = null + + private val title: Title? = null + private var timestamp: Timestamp? = null + + @SerializedName("*") + var contents: Contents? = null + + fun wiki(): String = wiki ?: "" + + fun id(): Long = id + + fun setId(id: Long) { + this.id = id + } + + fun key(): Long = + id + wiki().hashCode() + + fun type(): String = + type ?: "" + + fun title(): Title? = title + + fun getTimestamp(): Date = + timestamp?.date() ?: Date() + + fun setTimestamp(timestamp: Timestamp?) { + this.timestamp = timestamp + } + + val utcIso8601: String + get() = timestamp?.utciso8601 ?: "" + + val isFromWikidata: Boolean + get() = wiki() == "wikidatawiki" + + override fun toString(): String = + id.toString() + + class Title { + private val full: String? = null + private val text: String? = null + + fun text(): String = text ?: "" + + fun full(): String = full ?: "" + } + + class Timestamp { + internal var utciso8601: String? = null + + fun setUtciso8601(utciso8601: String?) { + this.utciso8601 = utciso8601 + } + + fun date(): Date { + try { + return iso8601DateParse(utciso8601 ?: "") + } catch (e: ParseException) { + Timber.e(e) + return Date() + } + } + } + + class Link { + var url: String? = null + get() = field ?: "" + val label: String? = null + get() = field ?: "" + val tooltip: String? = null + get() = field ?: "" + private val description: String? = null + val icon: String? = null + get() = field ?: "" + } + + class Links { + private var primary: JsonElement? = null + private var primaryLink: Link? = null + + fun setPrimary(primary: JsonElement?) { + this.primary = primary + } + + fun getPrimary(): Link? { + if (primary == null) { + return null + } + if (primaryLink == null && primary is JsonObject) { + primaryLink = defaultGson.fromJson(primary, Link::class.java) + } + return primaryLink + } + } + + class Contents { + val header: String? = null + get() = field ?: "" + var compactHeader: String? = null + get() = field ?: "" + val body: String? = null + get() = field ?: "" + private val icon: String? = null + var links: Links? = null + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoMarshaller.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoMarshaller.java deleted file mode 100644 index 427833512..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoMarshaller.java +++ /dev/null @@ -1,28 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.page; - -import android.location.Location; - -import androidx.annotation.Nullable; - -import org.json.JSONException; -import org.json.JSONObject; - -public final class GeoMarshaller { - @Nullable - public static String marshal(@Nullable Location object) { - if (object == null) { - return null; - } - - JSONObject jsonObj = new JSONObject(); - try { - jsonObj.put(GeoUnmarshaller.LATITUDE, object.getLatitude()); - jsonObj.put(GeoUnmarshaller.LONGITUDE, object.getLongitude()); - } catch (JSONException e) { - throw new RuntimeException(e); - } - return jsonObj.toString(); - } - - private GeoMarshaller() { } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoUnmarshaller.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoUnmarshaller.java deleted file mode 100644 index aa5952964..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/GeoUnmarshaller.java +++ /dev/null @@ -1,39 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.page; - -import android.location.Location; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.json.JSONException; -import org.json.JSONObject; - -public final class GeoUnmarshaller { - static final String LATITUDE = "latitude"; - static final String LONGITUDE = "longitude"; - - @Nullable - public static Location unmarshal(@Nullable String json) { - if (json == null) { - return null; - } - - JSONObject jsonObj; - try { - jsonObj = new JSONObject(json); - } catch (JSONException e) { - return null; - } - return unmarshal(jsonObj); - } - - @Nullable - public static Location unmarshal(@NonNull JSONObject jsonObj) { - Location ret = new Location((String) null); - ret.setLatitude(jsonObj.optDouble(LATITUDE)); - ret.setLongitude(jsonObj.optDouble(LONGITUDE)); - return ret; - } - - private GeoUnmarshaller() { } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.kt similarity index 56% rename from app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.java rename to app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.kt index 47aff28c2..a52fd0954 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.java +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/Namespace.kt @@ -1,34 +1,28 @@ -package fr.free.nrw.commons.wikidata.model.page; +package fr.free.nrw.commons.wikidata.model.page -import androidx.annotation.NonNull; - -import fr.free.nrw.commons.wikidata.model.EnumCode; -import fr.free.nrw.commons.wikidata.model.EnumCodeMap; +import fr.free.nrw.commons.wikidata.model.EnumCode +import fr.free.nrw.commons.wikidata.model.EnumCodeMap /** An enumeration describing the different possible namespace codes. Do not attempt to use this - * class to preserve URL path information such as Talk: or User: or localization. - * @see Wikipedia:Namespace - * @see Extension default namespaces - * @see NSNumber+MWKTitleNamespace.h (iOS implementation) - * @see Manual:Namespace - * @see Namespaces reported by API + * class to preserve URL path information such as Talk: or User: or localization. + * + * @see [Wikipedia:Namespace](https://en.wikipedia.org/wiki/Wikipedia:Namespace) + * @see [Extension default namespaces](https://www.mediawiki.org/wiki/Extension_default_namespaces) + * @see [NSNumber+MWKTitleNamespace.h + * @see [Manual:Namespace](https://www.mediawiki.org/wiki/Manual:Namespace.Built-in_namespaces) + * @see [Namespaces reported by API](https://en.wikipedia.org/w/api.php?action=query&meta=siteinfo&siprop=namespaces|namespacealiases)](https://github.com/wikimedia/wikipedia-ios/blob/master/Wikipedia/Code/NSNumber+MWKTitleNamespace.h) */ -public enum Namespace implements EnumCode { +enum class Namespace(private val code: Int) : EnumCode { MEDIA(-2), - SPECIAL(-1) { - @Override - public boolean talk() { - return false; - } - }, - MAIN(0), // Main or Article + SPECIAL(-1) { override fun talk(): Boolean = false }, + MAIN(0), // Main or Article TALK(1), USER(2), USER_TALK(3), - PROJECT(4), // WP alias - PROJECT_TALK(5), // WT alias - FILE(6), // Image alias - FILE_TALK(7), // Image talk alias + PROJECT(4), // WP alias + PROJECT_TALK(5), // WT alias + FILE(6), // Image alias + FILE_TALK(7), // Image talk alias MEDIAWIKI(8), MEDIAWIKI_TALK(9), TEMPLATE(10), @@ -137,38 +131,20 @@ public enum Namespace implements EnumCode { GADGET_DEFINITION_TALK(2303), TOPIC(2600); - private static final int TALK_MASK = 0x1; - private static final EnumCodeMap MAP = new EnumCodeMap<>(Namespace.class); + override fun code(): Int = code - private final int code; + fun special(): Boolean = this === SPECIAL - @NonNull - public static Namespace of(int code) { - return MAP.get(code); - } + fun main(): Boolean = this === MAIN - @Override - public int code() { - return code; - } + fun file(): Boolean = this === FILE - public boolean special() { - return this == SPECIAL; - } + open fun talk(): Boolean = (code and TALK_MASK) == TALK_MASK - public boolean main() { - return this == MAIN; - } + companion object { + private const val TALK_MASK = 0x1 + private val MAP = EnumCodeMap(Namespace::class.java) - public boolean file() { - return this == FILE; - } - - public boolean talk() { - return (code & TALK_MASK) == TALK_MASK; - } - - Namespace(int code) { - this.code = code; + fun of(code: Int): Namespace = MAP[code] } } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.java deleted file mode 100644 index 8b32252d8..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.java +++ /dev/null @@ -1,156 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.page; - -import android.location.Location; -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Date; - -/** - * Immutable class that contains metadata associated with a PageTitle. - */ -public class PageProperties implements Parcelable { - private final int pageId; - @NonNull private final Namespace namespace; - private final long revisionId; - private final Date lastModified; - private final String displayTitleText; - private final String editProtectionStatus; - private final int languageCount; - private final boolean isMainPage; - private final boolean isDisambiguationPage; - /** Nullable URL with no scheme. For example, foo.bar.com/ instead of http://foo.bar.com/. */ - @Nullable private final String leadImageUrl; - @Nullable private final String leadImageName; - @Nullable private final String titlePronunciationUrl; - @Nullable private final Location geo; - @Nullable private final String wikiBaseItem; - @Nullable private final String descriptionSource; - - /** - * True if the user who first requested this page can edit this page - * FIXME: This is not a true page property, since it depends on current user. - */ - private final boolean canEdit; - - public int getPageId() { - return pageId; - } - - public boolean isMainPage() { - return isMainPage; - } - - public boolean isDisambiguationPage() { - return isDisambiguationPage; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel parcel, int flags) { - parcel.writeInt(pageId); - parcel.writeInt(namespace.code()); - parcel.writeLong(revisionId); - parcel.writeLong(lastModified.getTime()); - parcel.writeString(displayTitleText); - parcel.writeString(titlePronunciationUrl); - parcel.writeString(GeoMarshaller.marshal(geo)); - parcel.writeString(editProtectionStatus); - parcel.writeInt(languageCount); - parcel.writeInt(canEdit ? 1 : 0); - parcel.writeInt(isMainPage ? 1 : 0); - parcel.writeInt(isDisambiguationPage ? 1 : 0); - parcel.writeString(leadImageUrl); - parcel.writeString(leadImageName); - parcel.writeString(wikiBaseItem); - parcel.writeString(descriptionSource); - } - - private PageProperties(Parcel in) { - pageId = in.readInt(); - namespace = Namespace.of(in.readInt()); - revisionId = in.readLong(); - lastModified = new Date(in.readLong()); - displayTitleText = in.readString(); - titlePronunciationUrl = in.readString(); - geo = GeoUnmarshaller.unmarshal(in.readString()); - editProtectionStatus = in.readString(); - languageCount = in.readInt(); - canEdit = in.readInt() == 1; - isMainPage = in.readInt() == 1; - isDisambiguationPage = in.readInt() == 1; - leadImageUrl = in.readString(); - leadImageName = in.readString(); - wikiBaseItem = in.readString(); - descriptionSource = in.readString(); - } - - public static final Parcelable.Creator CREATOR - = new Parcelable.Creator() { - @Override - public PageProperties createFromParcel(Parcel in) { - return new PageProperties(in); - } - - @Override - public PageProperties[] newArray(int size) { - return new PageProperties[size]; - } - }; - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - - PageProperties that = (PageProperties) o; - - return pageId == that.pageId - && namespace == that.namespace - && revisionId == that.revisionId - && lastModified.equals(that.lastModified) - && displayTitleText.equals(that.displayTitleText) - && TextUtils.equals(titlePronunciationUrl, that.titlePronunciationUrl) - && (geo == that.geo || geo != null && geo.equals(that.geo)) - && languageCount == that.languageCount - && canEdit == that.canEdit - && isMainPage == that.isMainPage - && isDisambiguationPage == that.isDisambiguationPage - && TextUtils.equals(editProtectionStatus, that.editProtectionStatus) - && TextUtils.equals(leadImageUrl, that.leadImageUrl) - && TextUtils.equals(leadImageName, that.leadImageName) - && TextUtils.equals(wikiBaseItem, that.wikiBaseItem); - } - - @Override - public int hashCode() { - int result = lastModified.hashCode(); - result = 31 * result + displayTitleText.hashCode(); - result = 31 * result + (titlePronunciationUrl != null ? titlePronunciationUrl.hashCode() : 0); - result = 31 * result + (geo != null ? geo.hashCode() : 0); - result = 31 * result + (editProtectionStatus != null ? editProtectionStatus.hashCode() : 0); - result = 31 * result + languageCount; - result = 31 * result + (isMainPage ? 1 : 0); - result = 31 * result + (isDisambiguationPage ? 1 : 0); - result = 31 * result + (leadImageUrl != null ? leadImageUrl.hashCode() : 0); - result = 31 * result + (leadImageName != null ? leadImageName.hashCode() : 0); - result = 31 * result + (wikiBaseItem != null ? wikiBaseItem.hashCode() : 0); - result = 31 * result + (canEdit ? 1 : 0); - result = 31 * result + pageId; - result = 31 * result + namespace.code(); - result = 31 * result + (int) revisionId; - return result; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.kt new file mode 100644 index 000000000..ce8873e8c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageProperties.kt @@ -0,0 +1,156 @@ +package fr.free.nrw.commons.wikidata.model.page + +import android.location.Location +import android.os.Parcel +import android.os.Parcelable +import android.text.TextUtils +import org.json.JSONException +import org.json.JSONObject +import java.util.Date + +/** + * Immutable class that contains metadata associated with a PageTitle. + */ +class PageProperties private constructor(parcel: Parcel) : Parcelable { + val pageId: Int = parcel.readInt() + private val namespace = Namespace.of(parcel.readInt()) + private val revisionId = parcel.readLong() + private val lastModified = Date(parcel.readLong()) + private val displayTitleText = parcel.readString() + private val editProtectionStatus = parcel.readString() + private val languageCount = parcel.readInt() + val isMainPage: Boolean = parcel.readInt() == 1 + val isDisambiguationPage: Boolean = parcel.readInt() == 1 + + /** Nullable URL with no scheme. For example, foo.bar.com/ instead of http://foo.bar.com/. */ + private val leadImageUrl = parcel.readString() + private val leadImageName = parcel.readString() + private val titlePronunciationUrl = parcel.readString() + private val geo = unmarshal(parcel.readString()) + private val wikiBaseItem = parcel.readString() + private val descriptionSource = parcel.readString() + + /** + * True if the user who first requested this page can edit this page + * FIXME: This is not a true page property, since it depends on current user. + */ + private val canEdit = parcel.readInt() == 1 + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeInt(pageId) + parcel.writeInt(namespace.code()) + parcel.writeLong(revisionId) + parcel.writeLong(lastModified.time) + parcel.writeString(displayTitleText) + parcel.writeString(titlePronunciationUrl) + parcel.writeString(marshal(geo)) + parcel.writeString(editProtectionStatus) + parcel.writeInt(languageCount) + parcel.writeInt(if (canEdit) 1 else 0) + parcel.writeInt(if (isMainPage) 1 else 0) + parcel.writeInt(if (isDisambiguationPage) 1 else 0) + parcel.writeString(leadImageUrl) + parcel.writeString(leadImageName) + parcel.writeString(wikiBaseItem) + parcel.writeString(descriptionSource) + } + + override fun equals(o: Any?): Boolean { + if (this === o) { + return true + } + if (o == null || javaClass != o.javaClass) { + return false + } + + val that = o as PageProperties + + return pageId == that.pageId && + namespace === that.namespace && + revisionId == that.revisionId && + lastModified == that.lastModified && + displayTitleText == that.displayTitleText && + TextUtils.equals(titlePronunciationUrl, that.titlePronunciationUrl) && + (geo === that.geo || geo != null && geo == that.geo) && + languageCount == that.languageCount && + canEdit == that.canEdit && + isMainPage == that.isMainPage && + isDisambiguationPage == that.isDisambiguationPage && + TextUtils.equals(editProtectionStatus, that.editProtectionStatus) && + TextUtils.equals(leadImageUrl, that.leadImageUrl) && + TextUtils.equals(leadImageName, that.leadImageName) && + TextUtils.equals(wikiBaseItem, that.wikiBaseItem) + } + + override fun hashCode(): Int { + var result = lastModified.hashCode() + result = 31 * result + displayTitleText.hashCode() + result = 31 * result + (titlePronunciationUrl?.hashCode() ?: 0) + result = 31 * result + (geo?.hashCode() ?: 0) + result = 31 * result + (editProtectionStatus?.hashCode() ?: 0) + result = 31 * result + languageCount + result = 31 * result + (if (isMainPage) 1 else 0) + result = 31 * result + (if (isDisambiguationPage) 1 else 0) + result = 31 * result + (leadImageUrl?.hashCode() ?: 0) + result = 31 * result + (leadImageName?.hashCode() ?: 0) + result = 31 * result + (wikiBaseItem?.hashCode() ?: 0) + result = 31 * result + (if (canEdit) 1 else 0) + result = 31 * result + pageId + result = 31 * result + namespace.code() + result = 31 * result + revisionId.toInt() + return result + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): PageProperties { + return PageProperties(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } +} + +private const val LATITUDE: String = "latitude" +private const val LONGITUDE: String = "longitude" + +private fun marshal(location: Location?): String? { + if (location == null) { + return null + } + + val jsonObj = JSONObject().apply { + try { + put(LATITUDE, location.latitude) + put(LONGITUDE, location.longitude) + } catch (e: JSONException) { + throw RuntimeException(e) + } + } + + return jsonObj.toString() +} + +private fun unmarshal(json: String?): Location? { + if (json == null) { + return null + } + + return try { + val jsonObject = JSONObject(json) + Location(null as String?).apply { + latitude = jsonObject.optDouble(LATITUDE) + longitude = jsonObject.optDouble(LONGITUDE) + } + } catch (e: JSONException) { + null + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.java deleted file mode 100644 index f22ff7609..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.java +++ /dev/null @@ -1,339 +0,0 @@ -package fr.free.nrw.commons.wikidata.model.page; - -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextUtils; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.gson.annotations.SerializedName; -import fr.free.nrw.commons.wikidata.model.WikiSite; -import java.io.UnsupportedEncodingException; -import java.net.URLDecoder; -import java.net.URLEncoder; -import java.text.Normalizer; -import java.util.Arrays; -import java.util.Locale; -import timber.log.Timber; - -/** - * Represents certain vital information about a page, including the title, namespace, - * and fragment (section anchor target). It can also contain a thumbnail URL for the - * page, and a short description retrieved from Wikidata. - * - * WARNING: This class is not immutable! Specifically, the thumbnail URL and the Wikidata - * description can be altered after construction. Therefore do NOT rely on all the fields - * of a PageTitle to remain constant for the lifetime of the object. - */ -public class PageTitle implements Parcelable { - - public static final Parcelable.Creator CREATOR - = new Parcelable.Creator() { - @Override - public PageTitle createFromParcel(Parcel in) { - return new PageTitle(in); - } - - @Override - public PageTitle[] newArray(int size) { - return new PageTitle[size]; - } - }; - - /** - * The localised namespace of the page as a string, or null if the page is in mainspace. - * - * This field contains the prefix of the page's title, as opposed to the namespace ID used by - * MediaWiki. Therefore, mainspace pages always have a null namespace, as they have no prefix, - * and the namespace of a page will depend on the language of the wiki the user is currently - * looking at. - * - * Examples: - * * [[Manchester]] on enwiki will have a namespace of null - * * [[Deutschland]] on dewiki will have a namespace of null - * * [[User:Deskana]] on enwiki will have a namespace of "User" - * * [[Utilisateur:Deskana]] on frwiki will have a namespace of "Utilisateur", even if you got - * to the page by going to [[User:Deskana]] and having MediaWiki automatically redirect you. - */ - // TODO: remove. This legacy code is the localized namespace name (File, Special, Talk, etc) but - // isn't consistent across titles. e.g., articles with colons, such as RTÉ News: Six One, - // are broken. - @Nullable private final String namespace; - @NonNull private final String text; - @Nullable private final String fragment; - @Nullable private String thumbUrl; - @SerializedName("site") @NonNull private final WikiSite wiki; - @Nullable private String description; - @Nullable private final PageProperties properties; - // TODO: remove after the restbase endpoint supports ZH variants. - @Nullable private String convertedText; - - /** - * Creates a new PageTitle object. - * Use this if you want to pass in a fragment portion separately from the title. - * - * @param prefixedText title of the page with optional namespace prefix - * @param fragment optional fragment portion - * @param wiki the wiki site the page belongs to - * @return a new PageTitle object matching the given input parameters - */ - public static PageTitle withSeparateFragment(@NonNull String prefixedText, - @Nullable String fragment, @NonNull WikiSite wiki) { - if (TextUtils.isEmpty(fragment)) { - return new PageTitle(prefixedText, wiki, null, (PageProperties) null); - } else { - // TODO: this class needs some refactoring to allow passing in a fragment - // without having to do string manipulations. - return new PageTitle(prefixedText + "#" + fragment, wiki, null, (PageProperties) null); - } - } - - public PageTitle(@Nullable final String namespace, @NonNull String text, @Nullable String fragment, @Nullable String thumbUrl, @NonNull WikiSite wiki) { - this.namespace = namespace; - this.text = text; - this.fragment = fragment; - this.wiki = wiki; - this.thumbUrl = thumbUrl; - properties = null; - } - - public PageTitle(@Nullable String text, @NonNull WikiSite wiki, @Nullable String thumbUrl, @Nullable String description, @Nullable PageProperties properties) { - this(text, wiki, thumbUrl, properties); - this.description = description; - } - - public PageTitle(@Nullable String text, @NonNull WikiSite wiki, @Nullable String thumbUrl, @Nullable String description) { - this(text, wiki, thumbUrl); - this.description = description; - } - - public PageTitle(@Nullable String namespace, @NonNull String text, @NonNull WikiSite wiki) { - this(namespace, text, null, null, wiki); - } - - public PageTitle(@Nullable String text, @NonNull WikiSite wiki, @Nullable String thumbUrl) { - this(text, wiki, thumbUrl, (PageProperties) null); - } - - public PageTitle(@Nullable String text, @NonNull WikiSite wiki) { - this(text, wiki, null); - } - - private PageTitle(@Nullable String text, @NonNull WikiSite wiki, @Nullable String thumbUrl, - @Nullable PageProperties properties) { - if (text == null) { - text = ""; - } - // FIXME: Does not handle mainspace articles with a colon in the title well at all - String[] fragParts = text.split("#", -1); - text = fragParts[0]; - if (fragParts.length > 1) { - this.fragment = decodeURL(fragParts[1]).replace(" ", "_"); - } else { - this.fragment = null; - } - - String[] parts = text.split(":", -1); - if (parts.length > 1) { - String namespaceOrLanguage = parts[0]; - if (Arrays.asList(Locale.getISOLanguages()).contains(namespaceOrLanguage)) { - this.namespace = null; - this.wiki = new WikiSite(wiki.authority(), namespaceOrLanguage); - } else { - this.wiki = wiki; - this.namespace = namespaceOrLanguage; - } - this.text = TextUtils.join(":", Arrays.copyOfRange(parts, 1, parts.length)); - } else { - this.wiki = wiki; - this.namespace = null; - this.text = parts[0]; - } - - this.thumbUrl = thumbUrl; - this.properties = properties; - } - - /** - * Decodes a URL-encoded string into its UTF-8 equivalent. If the string cannot be decoded, the - * original string is returned. - * @param url The URL-encoded string that you wish to decode. - * @return The decoded string, or the input string if the decoding failed. - */ - @NonNull private String decodeURL(@NonNull String url) { - try { - return URLDecoder.decode(url, "UTF-8"); - } catch (IllegalArgumentException e) { - // Swallow IllegalArgumentException (can happen with malformed encoding), and just - // return the original string. - Timber.d("URL decoding failed. String was: %s", url); - return url; - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - } - - @NonNull public WikiSite getWikiSite() { - return wiki; - } - - @NonNull public String getText() { - return text.replace(" ", "_"); - } - - @Nullable public String getFragment() { - return fragment; - } - - @Nullable public String getThumbUrl() { - return thumbUrl; - } - - public void setThumbUrl(@Nullable String thumbUrl) { - this.thumbUrl = thumbUrl; - } - - @Nullable public String getDescription() { - return description; - } - - public void setDescription(@Nullable String description) { - this.description = description; - } - - @NonNull - public String getConvertedText() { - return convertedText == null ? getPrefixedText() : convertedText; - } - - public void setConvertedText(@Nullable String convertedText) { - this.convertedText = convertedText; - } - - @NonNull public String getDisplayText() { - return getPrefixedText().replace("_", " "); - } - - @NonNull public String getDisplayTextWithoutNamespace() { - return text.replace("_", " "); - } - - public boolean hasProperties() { - return properties != null; - } - - @Nullable public PageProperties getProperties() { - return properties; - } - - public boolean isMainPage() { - return properties != null && properties.isMainPage(); - } - - public boolean isDisambiguationPage() { - return properties != null && properties.isDisambiguationPage(); - } - - public String getCanonicalUri() { - return getUriForDomain(getWikiSite().authority()); - } - - public String getMobileUri() { - return getUriForDomain(getWikiSite().mobileAuthority()); - } - - public String getUriForAction(String action) { - try { - return String.format( - "%1$s://%2$s/w/index.php?title=%3$s&action=%4$s", - getWikiSite().scheme(), - getWikiSite().authority(), - URLEncoder.encode(getPrefixedText(), "utf-8"), - action - ); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - } - - public String getPrefixedText() { - - // TODO: find a better way to check if the namespace is a ISO Alpha2 Code (two digits country code) - return namespace == null ? getText() : addUnderscores(namespace) + ":" + getText(); - } - - private String addUnderscores(@NonNull String text) { - return text.replace(" ", "_"); - } - - @Override public void writeToParcel(Parcel parcel, int flags) { - parcel.writeString(namespace); - parcel.writeString(text); - parcel.writeString(fragment); - parcel.writeParcelable(wiki, flags); - parcel.writeParcelable(properties, flags); - parcel.writeString(thumbUrl); - parcel.writeString(description); - parcel.writeString(convertedText); - } - - @Override public boolean equals(Object o) { - if (!(o instanceof PageTitle)) { - return false; - } - - PageTitle other = (PageTitle)o; - // Not using namespace directly since that can be null - return normalizedEquals(other.getPrefixedText(), getPrefixedText()) && other.wiki.equals(wiki); - } - - // Compare two strings based on their normalized form, using the Unicode Normalization Form C. - // This should be used when comparing or verifying strings that will be exchanged between - // different platforms (iOS, desktop, etc) that may encode strings using inconsistent - // composition, especially for accents, diacritics, etc. - private boolean normalizedEquals(@Nullable String str1, @Nullable String str2) { - if (str1 == null || str2 == null) { - return (str1 == null && str2 == null); - } - return Normalizer.normalize(str1, Normalizer.Form.NFC) - .equals(Normalizer.normalize(str2, Normalizer.Form.NFC)); - } - - @Override public int hashCode() { - int result = getPrefixedText().hashCode(); - result = 31 * result + wiki.hashCode(); - return result; - } - - @Override public String toString() { - return getPrefixedText(); - } - - @Override public int describeContents() { - return 0; - } - - private String getUriForDomain(String domain) { - try { - return String.format( - "%1$s://%2$s/wiki/%3$s%4$s", - getWikiSite().scheme(), - domain, - URLEncoder.encode(getPrefixedText(), "utf-8"), - (this.fragment != null && this.fragment.length() > 0) ? ("#" + this.fragment) : "" - ); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - } - - private PageTitle(Parcel in) { - namespace = in.readString(); - text = in.readString(); - fragment = in.readString(); - wiki = in.readParcelable(WikiSite.class.getClassLoader()); - properties = in.readParcelable(PageProperties.class.getClassLoader()); - thumbUrl = in.readString(); - description = in.readString(); - convertedText = in.readString(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.kt new file mode 100644 index 000000000..b039f55d6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/page/PageTitle.kt @@ -0,0 +1,284 @@ +package fr.free.nrw.commons.wikidata.model.page + +import android.os.Parcel +import android.os.Parcelable +import android.text.TextUtils +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.model.WikiSite +import timber.log.Timber +import java.io.UnsupportedEncodingException +import java.net.URLDecoder +import java.net.URLEncoder +import java.text.Normalizer +import java.util.Arrays +import java.util.Locale + +/** + * Represents certain vital information about a page, including the title, namespace, + * and fragment (section anchor target). It can also contain a thumbnail URL for the + * page, and a short description retrieved from Wikidata. + * + * WARNING: This class is not immutable! Specifically, the thumbnail URL and the Wikidata + * description can be altered after construction. Therefore do NOT rely on all the fields + * of a PageTitle to remain constant for the lifetime of the object. + */ +class PageTitle : Parcelable { + /** + * The localised namespace of the page as a string, or null if the page is in mainspace. + * + * This field contains the prefix of the page's title, as opposed to the namespace ID used by + * MediaWiki. Therefore, mainspace pages always have a null namespace, as they have no prefix, + * and the namespace of a page will depend on the language of the wiki the user is currently + * looking at. + * + * Examples: + * * [[Manchester]] on enwiki will have a namespace of null + * * [[Deutschland]] on dewiki will have a namespace of null + * * [[User:Deskana]] on enwiki will have a namespace of "User" + * * [[Utilisateur:Deskana]] on frwiki will have a namespace of "Utilisateur", even if you got + * to the page by going to [[User:Deskana]] and having MediaWiki automatically redirect you. + */ + // TODO: remove. This legacy code is the localized namespace name (File, Special, Talk, etc) but + // isn't consistent across titles. e.g., articles with colons, such as RTÉ News: Six One, + // are broken. + private val namespace: String? + private val text: String + val fragment: String? + var thumbUrl: String? + + @SerializedName("site") + val wikiSite: WikiSite + var description: String? = null + private val properties: PageProperties? + + // TODO: remove after the restbase endpoint supports ZH variants. + private var convertedText: String? = null + + constructor(namespace: String?, text: String, fragment: String?, thumbUrl: String?, wiki: WikiSite) { + this.namespace = namespace + this.text = text + this.fragment = fragment + this.thumbUrl = thumbUrl + wikiSite = wiki + properties = null + } + + constructor(text: String?, wiki: WikiSite, thumbUrl: String?, description: String?, properties: PageProperties?) : this(text, wiki, thumbUrl, properties) { + this.description = description + } + + constructor(text: String?, wiki: WikiSite, thumbUrl: String?, description: String?) : this(text, wiki, thumbUrl) { + this.description = description + } + + constructor(namespace: String?, text: String, wiki: WikiSite) : this(namespace, text, null, null, wiki) + + @JvmOverloads + constructor(text: String?, wiki: WikiSite, thumbUrl: String? = null) : this(text, wiki, thumbUrl, null as PageProperties?) + + private constructor(input: String?, wiki: WikiSite, thumbUrl: String?, properties: PageProperties?) { + var text = input ?: "" + // FIXME: Does not handle mainspace articles with a colon in the title well at all + val fragParts = text.split("#".toRegex()).toTypedArray() + text = fragParts[0] + fragment = if (fragParts.size > 1) { + decodeURL(fragParts[1]).replace(" ", "_") + } else { + null + } + + val parts = text.split(":".toRegex()).toTypedArray() + if (parts.size > 1) { + val namespaceOrLanguage = parts[0] + if (Arrays.asList(*Locale.getISOLanguages()).contains(namespaceOrLanguage)) { + namespace = null + wikiSite = WikiSite(wiki.authority(), namespaceOrLanguage) + } else { + wikiSite = wiki + namespace = namespaceOrLanguage + } + this.text = TextUtils.join(":", Arrays.copyOfRange(parts, 1, parts.size)) + } else { + wikiSite = wiki + namespace = null + this.text = parts[0] + } + + this.thumbUrl = thumbUrl + this.properties = properties + } + + /** + * Decodes a URL-encoded string into its UTF-8 equivalent. If the string cannot be decoded, the + * original string is returned. + * @param url The URL-encoded string that you wish to decode. + * @return The decoded string, or the input string if the decoding failed. + */ + private fun decodeURL(url: String): String { + try { + return URLDecoder.decode(url, "UTF-8") + } catch (e: IllegalArgumentException) { + // Swallow IllegalArgumentException (can happen with malformed encoding), and just + // return the original string. + Timber.d("URL decoding failed. String was: %s", url) + return url + } catch (e: UnsupportedEncodingException) { + throw RuntimeException(e) + } + } + + private fun getTextWithoutSpaces(): String = + text.replace(" ", "_") + + fun getConvertedText(): String = + if (convertedText == null) prefixedText else convertedText!! + + fun setConvertedText(convertedText: String?) { + this.convertedText = convertedText + } + + val displayText: String + get() = prefixedText.replace("_", " ") + + val displayTextWithoutNamespace: String + get() = text.replace("_", " ") + + fun hasProperties(): Boolean = + properties != null + + val isMainPage: Boolean + get() = properties != null && properties.isMainPage + + val isDisambiguationPage: Boolean + get() = properties != null && properties.isDisambiguationPage + + val canonicalUri: String + get() = getUriForDomain(wikiSite.authority()) + + val mobileUri: String + get() = getUriForDomain(wikiSite.mobileAuthority()) + + fun getUriForAction(action: String?): String { + try { + return String.format( + "%1\$s://%2\$s/w/index.php?title=%3\$s&action=%4\$s", + wikiSite.scheme(), + wikiSite.authority(), + URLEncoder.encode(prefixedText, "utf-8"), + action + ) + } catch (e: UnsupportedEncodingException) { + throw RuntimeException(e) + } + } + + // TODO: find a better way to check if the namespace is a ISO Alpha2 Code (two digits country code) + val prefixedText: String + get() = namespace?.let { addUnderscores(it) + ":" + getTextWithoutSpaces() } + ?: getTextWithoutSpaces() + + private fun addUnderscores(text: String): String = + text.replace(" ", "_") + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(namespace) + parcel.writeString(text) + parcel.writeString(fragment) + parcel.writeParcelable(wikiSite, flags) + parcel.writeParcelable(properties, flags) + parcel.writeString(thumbUrl) + parcel.writeString(description) + parcel.writeString(convertedText) + } + + override fun equals(o: Any?): Boolean { + if (o !is PageTitle) { + return false + } + + val other = o + // Not using namespace directly since that can be null + return normalizedEquals(other.prefixedText, prefixedText) && other.wikiSite.equals(wikiSite) + } + + // Compare two strings based on their normalized form, using the Unicode Normalization Form C. + // This should be used when comparing or verifying strings that will be exchanged between + // different platforms (iOS, desktop, etc) that may encode strings using inconsistent + // composition, especially for accents, diacritics, etc. + private fun normalizedEquals(str1: String?, str2: String?): Boolean { + if (str1 == null || str2 == null) { + return (str1 == null && str2 == null) + } + return (Normalizer.normalize(str1, Normalizer.Form.NFC) + == Normalizer.normalize(str2, Normalizer.Form.NFC)) + } + + override fun hashCode(): Int { + var result = prefixedText.hashCode() + result = 31 * result + wikiSite.hashCode() + return result + } + + override fun toString(): String = + prefixedText + + override fun describeContents(): Int = 0 + + private fun getUriForDomain(domain: String): String = try { + String.format( + "%1\$s://%2\$s/wiki/%3\$s%4\$s", + wikiSite.scheme(), + domain, + URLEncoder.encode(prefixedText, "utf-8"), + if ((fragment != null && fragment.length > 0)) ("#$fragment") else "" + ) + } catch (e: UnsupportedEncodingException) { + throw RuntimeException(e) + } + + private constructor(parcel: Parcel) { + namespace = parcel.readString() + text = parcel.readString()!! + fragment = parcel.readString() + wikiSite = parcel.readParcelable(WikiSite::class.java.classLoader)!! + properties = parcel.readParcelable(PageProperties::class.java.classLoader) + thumbUrl = parcel.readString() + description = parcel.readString() + convertedText = parcel.readString() + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): PageTitle { + return PageTitle(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + /** + * Creates a new PageTitle object. + * Use this if you want to pass in a fragment portion separately from the title. + * + * @param prefixedText title of the page with optional namespace prefix + * @param fragment optional fragment portion + * @param wiki the wiki site the page belongs to + * @return a new PageTitle object matching the given input parameters + */ + fun withSeparateFragment( + prefixedText: String, + fragment: String?, wiki: WikiSite + ): PageTitle { + return if (TextUtils.isEmpty(fragment)) { + PageTitle(prefixedText, wiki, null, null as PageProperties?) + } else { + // TODO: this class needs some refactoring to allow passing in a fragment + // without having to do string manipulations. + PageTitle("$prefixedText#$fragment", wiki, null, null as PageProperties?) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.java deleted file mode 100644 index 3a4ac9702..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.java +++ /dev/null @@ -1,17 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; - -public class ImageDetails { - - private String name; - private String title; - - @NonNull public String getName() { - return name; - } - - @NonNull public String getTitle() { - return title; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.kt new file mode 100644 index 000000000..d6e2f57dd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ImageDetails.kt @@ -0,0 +1,6 @@ +package fr.free.nrw.commons.wikidata.mwapi + +class ImageDetails { + val name: String? = null + val title: String? = null +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.java deleted file mode 100644 index a71e910c3..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.collection.ArraySet; - -import com.google.gson.annotations.SerializedName; - -import java.util.Collections; -import java.util.List; -import java.util.Set; - - -public class ListUserResponse { - @SerializedName("name") @Nullable private String name; - private long userid; - @Nullable private List groups; - - @Nullable public String name() { - return name; - } - - @NonNull public Set getGroups() { - return groups != null ? new ArraySet<>(groups) : Collections.emptySet(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.kt new file mode 100644 index 000000000..f5a04fc8e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/ListUserResponse.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.wikidata.mwapi + +class ListUserResponse { + private val name: String? = null + private val userid: Long = 0 + private val groups: List? = null + + fun name(): String? = name + + fun getGroups(): Set = + groups?.toSet() ?: emptySet() +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.java deleted file mode 100644 index f52681576..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.java +++ /dev/null @@ -1,44 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.Nullable; -import java.util.List; - -public class MwException extends RuntimeException { - @Nullable private final MwServiceError error; - - @Nullable private final List errors; - - public MwException(@Nullable MwServiceError error, - @Nullable final List errors) { - this.error = error; - this.errors = errors; - } - - public String getErrorCode() { - if(error!=null) { - return error.getCode(); - } - return errors != null ? errors.get(0).getCode() : null; - } - - @Nullable public MwServiceError getError() { - return error; - } - - @Nullable - public String getTitle() { - if (error != null) { - return error.getTitle(); - } - return errors != null ? errors.get(0).getTitle() : null; - } - - @Override - @Nullable - public String getMessage() { - if (error != null) { - return error.getDetails(); - } - return errors != null ? errors.get(0).getDetails() : null; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.kt new file mode 100644 index 000000000..9b003ecfa --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwException.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.wikidata.mwapi + +class MwException( + val error: MwServiceError?, + private val errors: List? +) : RuntimeException() { + val errorCode: String? + get() = error?.code ?: errors?.get(0)?.code + + val title: String? + get() = error?.title ?: errors?.get(0)?.title + + override val message: String? + get() = error?.details ?: errors?.get(0)?.details +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.java deleted file mode 100644 index 294fdad0a..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.Nullable; - -public class MwPostResponse extends MwResponse { - private int success; - - public boolean success(@Nullable String result) { - return "success".equals(result); - } - - public int getSuccessVal() { - return success; - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.kt new file mode 100644 index 000000000..a4ed55b7b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwPostResponse.kt @@ -0,0 +1,9 @@ +package fr.free.nrw.commons.wikidata.mwapi + +open class MwPostResponse : MwResponse() { + val successVal: Int = 0 + + fun success(result: String?): Boolean = + "success" == result +} + diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.java deleted file mode 100644 index be886b205..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.java +++ /dev/null @@ -1,229 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo; -import fr.free.nrw.commons.wikidata.model.BaseModel; - -import java.util.Collections; -import java.util.List; - -/** - * A class representing a standard page object as returned by the MediaWiki API. - */ -public class MwQueryPage extends BaseModel { - private int pageid; - private int index; - @NonNull private String title; - @NonNull private CategoryInfo categoryinfo; - @Nullable private List revisions; - @SerializedName("fileusage") @Nullable private List fileUsages; - @SerializedName("globalusage") @Nullable private List globalUsages; - @Nullable private List coordinates; - @Nullable private List categories; - @Nullable private Thumbnail thumbnail; - @Nullable private String description; - @SerializedName("imageinfo") @Nullable private List imageInfo; - @Nullable private String redirectFrom; - @Nullable private String convertedFrom; - @Nullable private String convertedTo; - - @NonNull public String title() { - return title; - } - - @NonNull public CategoryInfo categoryInfo() { - return categoryinfo; - } - - public int index() { - return index; - } - - @Nullable public List revisions() { - return revisions; - } - - @Nullable public List categories() { - return categories; - } - - @Nullable public List coordinates() { - // TODO: Handle null values in lists during deserialization, perhaps with a new - // @RequiredElements annotation and corresponding TypeAdapter - if (coordinates != null) { - coordinates.removeAll(Collections.singleton(null)); - } - return coordinates; - } - - public int pageId() { - return pageid; - } - - @Nullable public String thumbUrl() { - return thumbnail != null ? thumbnail.source() : null; - } - - @Nullable public String description() { - return description; - } - - @Nullable public ImageInfo imageInfo() { - return imageInfo != null ? imageInfo.get(0) : null; - } - - public void redirectFrom(@Nullable String from) { - redirectFrom = from; - } - - public void convertedFrom(@Nullable String from) { - convertedFrom = from; - } - - public void convertedTo(@Nullable String to) { - convertedTo = to; - } - - public void appendTitleFragment(@Nullable String fragment) { - title += "#" + fragment; - } - - public boolean checkWhetherFileIsUsedInWikis() { - if (globalUsages != null && globalUsages.size() > 0) { - return true; - } - - if (fileUsages == null || fileUsages.size() == 0) { - return false; - } - - final int totalCount = fileUsages.size(); - - /* Ignore usage under https://commons.wikimedia.org/wiki/User:Didym/Mobile_upload/ - which has been a gallery of all of our uploads since 2014 */ - for (final FileUsage fileUsage : fileUsages) { - if ( ! fileUsage.title().contains("User:Didym/Mobile upload")) { - return true; - } - } - - return false; - } - - public static class Revision { - @SerializedName("revid") private long revisionId; - private String user; - @SerializedName("contentformat") @NonNull private String contentFormat; - @SerializedName("contentmodel") @NonNull private String contentModel; - @SerializedName("timestamp") @NonNull private String timeStamp; - @NonNull private String content; - - @NonNull public String content() { - return content; - } - - @NonNull public String timeStamp() { - return StringUtils.defaultString(timeStamp); - } - - public long getRevisionId() { - return revisionId; - } - - @NonNull - public String getUser() { - return StringUtils.defaultString(user); - } - } - - public static class Coordinates { - @Nullable private Double lat; - @Nullable private Double lon; - - @Nullable public Double lat() { - return lat; - } - @Nullable public Double lon() { - return lon; - } - } - - public static class CategoryInfo { - private boolean hidden; - private int size; - private int pages; - private int files; - private int subcats; - public boolean isHidden() { - return hidden; - } - } - - static class Thumbnail { - private String source; - private int width; - private int height; - String source() { - return source; - } - } - - public static class GlobalUsage { - @SerializedName("title") private String title; - @SerializedName("wiki")private String wiki; - @SerializedName("url") private String url; - - public String getTitle() { - return title; - } - - public String getWiki() { - return wiki; - } - - public String getUrl() { - return url; - } - } - - public static class FileUsage { - @SerializedName("pageid") private int pageid; - @SerializedName("ns") private int ns; - @SerializedName("title") private String title; - - public int pageId() { - return pageid; - } - - public int ns() { - return ns; - } - - public String title() { - return title; - } - } - - public static class Category { - private int ns; - @SuppressWarnings("unused,NullableProblems") @Nullable private String title; - private boolean hidden; - - public int ns() { - return ns; - } - - @NonNull public String title() { - return StringUtils.defaultString(title); - } - - public boolean hidden() { - return hidden; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.kt new file mode 100644 index 000000000..74dfa511e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryPage.kt @@ -0,0 +1,186 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import androidx.annotation.VisibleForTesting +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.model.BaseModel +import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo +import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage.GlobalUsage + +/** + * A class representing a standard page object as returned by the MediaWiki API. + */ +class MwQueryPage : BaseModel() { + private val pageid = 0 + private val index = 0 + private var title: String? = null + private val categoryinfo: CategoryInfo? = null + private val revisions: List? = null + + @SerializedName("fileusage") + private val fileUsages: List? = null + + @SerializedName("globalusage") + private val globalUsages: List? = null + private val coordinates: MutableList? = null + private val categories: List? = null + private val thumbnail: Thumbnail? = null + private val description: String? = null + + @SerializedName("imageinfo") + private val imageInfo: List? = null + private var redirectFrom: String? = null + private var convertedFrom: String? = null + private var convertedTo: String? = null + + fun title(): String = title!! + + fun categoryInfo(): CategoryInfo = categoryinfo!! + + fun index(): Int = index + + fun revisions(): List? = revisions + + fun categories(): List? = categories + + // TODO: Handle null values in lists during deserialization, perhaps with a new + // @RequiredElements annotation and corresponding TypeAdapter + fun coordinates(): List? = coordinates?.filterNotNull() + + fun pageId(): Int = pageid + + fun thumbUrl(): String? = thumbnail?.source() + + fun description(): String? = description + + fun imageInfo(): ImageInfo? = imageInfo?.get(0) + + fun redirectFrom(from: String?) { + redirectFrom = from + } + + fun convertedFrom(from: String?) { + convertedFrom = from + } + + fun convertedTo(to: String?) { + convertedTo = to + } + + fun appendTitleFragment(fragment: String?) { + title += "#$fragment" + } + + fun checkWhetherFileIsUsedInWikis(): Boolean { + return checkWhetherFileIsUsedInWikis(globalUsages, fileUsages) + } + + class Revision { + @SerializedName("revid") + private val revisionId: Long = 0 + private val user: String? = null + @SerializedName("contentformat") + private val contentFormat: String? = null + @SerializedName("contentmodel") + private val contentModel: String? = null + @SerializedName("timestamp") + private val timeStamp: String? = null + private val content: String? = null + + fun revisionId(): Long = revisionId + + fun user(): String = user ?: "" + + fun content(): String = content!! + + fun timeStamp(): String = timeStamp ?: "" + } + + class Coordinates { + private val lat: Double? = null + private val lon: Double? = null + + fun lat(): Double? = lat + + fun lon(): Double? = lon + } + + class CategoryInfo { + val isHidden: Boolean = false + private val size = 0 + private val pages = 0 + private val files = 0 + private val subcats = 0 + } + + internal class Thumbnail { + private val source: String? = null + private val width = 0 + private val height = 0 + + fun source(): String? = source + } + + class GlobalUsage { + @SerializedName("title") + val title: String? = null + + @SerializedName("wiki") + val wiki: String? = null + + @SerializedName("url") + val url: String? = null + } + + class FileUsage { + @SerializedName("pageid") + private val pageid = 0 + + @SerializedName("ns") + private val ns = 0 + + @SerializedName("title") + private var title: String? = null + + fun pageId(): Int = pageid + + fun ns(): Int = ns + + fun title(): String = title ?: "" + + fun setTitle(value: String) { + title = value + } + } + + class Category { + private val ns = 0 + private val title: String? = null + private val hidden = false + + fun ns(): Int = ns + + fun title(): String = title ?: "" + + fun hidden(): Boolean = hidden + } +} + +@VisibleForTesting +fun checkWhetherFileIsUsedInWikis( + globalUsages: List?, + fileUsages: List? +): Boolean { + if (!globalUsages.isNullOrEmpty()) { + return true + } + + if (fileUsages.isNullOrEmpty()) { + return false + } + + /* Ignore usage under https://commons.wikimedia.org/wiki/User:Didym/Mobile_upload/ + which has been a gallery of all of our uploads since 2014 */ + return fileUsages.filterNot { + it.title().contains("User:Didym/Mobile upload") + }.isNotEmpty() +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.java deleted file mode 100644 index 871a2c31b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import java.util.Map; - -public class MwQueryResponse extends MwResponse { - - @SerializedName("continue") @Nullable private Map continuation; - - @SerializedName("query") @Nullable private MwQueryResult query; - - @Nullable public Map continuation() { - return continuation; - } - - @Nullable public MwQueryResult query() { - return query; - } - - public boolean success() { - return query != null; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.kt new file mode 100644 index 000000000..875a3cc2e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResponse.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import com.google.gson.annotations.SerializedName + +class MwQueryResponse : MwResponse() { + @SerializedName("continue") + private val continuation: Map? = null + private val query: MwQueryResult? = null + + fun continuation(): Map? = continuation + + fun query(): MwQueryResult? = query + + fun success(): Boolean = query != null +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.java deleted file mode 100644 index 303400160..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.java +++ /dev/null @@ -1,187 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter; -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo; -import fr.free.nrw.commons.wikidata.model.BaseModel; -import fr.free.nrw.commons.wikidata.model.notifications.Notification; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - - -public class MwQueryResult extends BaseModel implements PostProcessingTypeAdapter.PostProcessable { - @SerializedName("pages") @Nullable private List pages; - @Nullable private List redirects; - @Nullable private List converted; - @SerializedName("userinfo") private UserInfo userInfo; - @Nullable private List users; - @Nullable private Tokens tokens; - @Nullable private NotificationList notifications; - @SerializedName("allimages") @Nullable private List allImages; - - @Nullable public List pages() { - return pages; - } - - @Nullable public MwQueryPage firstPage() { - if (pages != null && pages.size() > 0) { - return pages.get(0); - } - return null; - } - - @NonNull - public List allImages() { - return allImages == null ? Collections.emptyList() : allImages; - } - - @Nullable public UserInfo userInfo() { - return userInfo; - } - - @Nullable public String csrfToken() { - return tokens != null ? tokens.csrf() : null; - } - - @Nullable public String loginToken() { - return tokens != null ? tokens.login() : null; - } - - @Nullable public NotificationList notifications() { - return notifications; - } - - @Nullable public ListUserResponse getUserResponse(@NonNull String userName) { - if (users != null) { - for (ListUserResponse user : users) { - // MediaWiki user names are case sensitive, but the first letter is always capitalized. - if (StringUtils.capitalize(userName).equals(user.name())) { - return user; - } - } - } - return null; - } - - @NonNull public Map images() { - Map result = new HashMap<>(); - if (pages != null) { - for (MwQueryPage page : pages) { - if (page.imageInfo() != null) { - result.put(page.title(), page.imageInfo()); - } - } - } - return result; - } - - @Override - public void postProcess() { - resolveConvertedTitles(); - resolveRedirectedTitles(); - } - - private void resolveRedirectedTitles() { - if (redirects == null || pages == null) { - return; - } - for (MwQueryPage page : pages) { - for (MwQueryResult.Redirect redirect : redirects) { - // TODO: Looks like result pages and redirects can also be matched on the "index" - // property. Confirm in the API docs and consider updating. - if (page.title().equals(redirect.to())) { - page.redirectFrom(redirect.from()); - if (redirect.toFragment() != null) { - page.appendTitleFragment(redirect.toFragment()); - } - } - } - } - } - - private void resolveConvertedTitles() { - if (converted == null || pages == null) { - return; - } - // noinspection ConstantConditions - for (MwQueryResult.ConvertedTitle convertedTitle : converted) { - // noinspection ConstantConditions - for (MwQueryPage page : pages) { - if (page.title().equals(convertedTitle.to())) { - page.convertedFrom(convertedTitle.from()); - page.convertedTo(convertedTitle.to()); - } - } - } - } - - private static class Redirect { - private int index; - @Nullable private String from; - @Nullable private String to; - @SerializedName("tofragment") @Nullable private String toFragment; - - @Nullable public String to() { - return to; - } - - @Nullable public String from() { - return from; - } - - @Nullable public String toFragment() { - return toFragment; - } - } - - public static class ConvertedTitle { - @Nullable private String from; - @Nullable private String to; - - @Nullable public String to() { - return to; - } - - @Nullable public String from() { - return from; - } - } - - private static class Tokens { - @SuppressWarnings("unused,NullableProblems") @SerializedName("csrftoken") - @Nullable private String csrf; - @SuppressWarnings("unused,NullableProblems") @SerializedName("createaccounttoken") - @Nullable private String createAccount; - @SuppressWarnings("unused,NullableProblems") @SerializedName("logintoken") - @Nullable private String login; - - @Nullable private String csrf() { - return csrf; - } - - @Nullable private String createAccount() { - return createAccount; - } - - @Nullable private String login() { - return login; - } - } - - public static class NotificationList { - @Nullable - private List list; - @Nullable - public List list() { - return list; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.kt new file mode 100644 index 000000000..8c898d848 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwQueryResult.kt @@ -0,0 +1,134 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter.PostProcessable +import fr.free.nrw.commons.wikidata.model.BaseModel +import fr.free.nrw.commons.wikidata.model.gallery.ImageInfo +import fr.free.nrw.commons.wikidata.model.notifications.Notification +import org.apache.commons.lang3.StringUtils + +class MwQueryResult : BaseModel(), PostProcessable { + private val pages: List? = null + private val redirects: List? = null + private val converted: List? = null + + @SerializedName("userinfo") + private val userInfo: UserInfo? = null + private val users: List? = null + private val tokens: Tokens? = null + private val notifications: NotificationList? = null + + @SerializedName("allimages") + private val allImages: List? = null + + fun pages(): List? = pages + + fun firstPage(): MwQueryPage? = pages?.firstOrNull() + + fun allImages(): List = allImages ?: emptyList() + + fun userInfo(): UserInfo? = userInfo + + fun csrfToken(): String? = tokens?.csrf() + + fun loginToken(): String? = tokens?.login() + + fun notifications(): NotificationList? = notifications + + fun getUserResponse(userName: String): ListUserResponse? = + users?.find { StringUtils.capitalize(userName) == it.name() } + + fun images() = buildMap { + pages?.forEach { page -> + page.imageInfo()?.let { + put(page.title(), it) + } + } + } + + override fun postProcess() { + resolveConvertedTitles() + resolveRedirectedTitles() + } + + private fun resolveRedirectedTitles() { + if (redirects == null || pages == null) { + return + } + + pages.forEach { page -> + redirects.forEach { redirect -> + // TODO: Looks like result pages and redirects can also be matched on the "index" + // property. Confirm in the API docs and consider updating. + if (page.title() == redirect.to()) { + page.redirectFrom(redirect.from()) + if (redirect.toFragment() != null) { + page.appendTitleFragment(redirect.toFragment()) + } + } + } + } + } + + private fun resolveConvertedTitles() { + if (converted == null || pages == null) { + return + } + + converted.forEach { convertedTitle -> + pages.forEach { page -> + if (page.title() == convertedTitle.to()) { + page.convertedFrom(convertedTitle.from()) + page.convertedTo(convertedTitle.to()) + } + } + } + } + + private class Redirect { + private val index = 0 + private val from: String? = null + private val to: String? = null + + @SerializedName("tofragment") + private val toFragment: String? = null + + fun to(): String? = to + + fun from(): String? = from + + fun toFragment(): String? = toFragment + } + + class ConvertedTitle { + private val from: String? = null + private val to: String? = null + + fun to(): String? = to + + fun from(): String? = from + } + + private class Tokens { + @SerializedName("csrftoken") + private val csrf: String? = null + + @SerializedName("createaccounttoken") + private val createAccount: String? = null + + @SerializedName("logintoken") + private val login: String? = null + + fun csrf(): String? = csrf + + fun createAccount(): String? = createAccount + + fun login(): String? = login + } + + class NotificationList { + private val list: List? = null + + fun list(): List? = list + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.java deleted file mode 100644 index 52662cd12..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.annotations.SerializedName; - -import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter; -import fr.free.nrw.commons.wikidata.model.BaseModel; - -import java.util.List; - -public abstract class MwResponse extends BaseModel implements PostProcessingTypeAdapter.PostProcessable { - @SuppressWarnings({"unused"}) @Nullable private List errors; - @SuppressWarnings("unused,NullableProblems") @SerializedName("servedby") @NonNull private String servedBy; - - @Override - public void postProcess() { - if (errors != null && !errors.isEmpty()) { - throw new MwException(errors.get(0), errors); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.kt new file mode 100644 index 000000000..02a637b14 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwResponse.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter.PostProcessable +import fr.free.nrw.commons.wikidata.model.BaseModel + +abstract class MwResponse : BaseModel(), PostProcessable { + private val errors: List? = null + + @SerializedName("servedby") + private val servedBy: String? = null + + override fun postProcess() { + if (!errors.isNullOrEmpty()) { + throw MwException(errors[0], errors) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.java deleted file mode 100644 index 4798a19b4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.java +++ /dev/null @@ -1,29 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.wikidata.model.BaseModel; - -/** - * Gson POJO for a MediaWiki API error. - */ -public class MwServiceError extends BaseModel { - - @Nullable private String code; - @Nullable private String text; - - @NonNull public String getTitle() { - return StringUtils.defaultString(code); - } - - @NonNull public String getDetails() { - return StringUtils.defaultString(text); - } - - @Nullable - public String getCode() { - return code; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.kt new file mode 100644 index 000000000..1408efbef --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/MwServiceError.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.wikidata.mwapi + +import fr.free.nrw.commons.wikidata.model.BaseModel +import org.apache.commons.lang3.StringUtils + +/** + * Gson POJO for a MediaWiki API error. + */ +class MwServiceError : BaseModel() { + val code: String? = null + private val text: String? = null + + val title: String + get() = code ?: "" + + val details: String + get() = text ?: "" +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java deleted file mode 100644 index 3ac9e3915..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java +++ /dev/null @@ -1,34 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import java.util.Map; - - -public class UserInfo { - @NonNull private String name; - @NonNull private int id; - - //Block information - private int blockid; - private String blockedby; - private int blockedbyid; - private String blockreason; - private String blocktimestamp; - private String blockexpiry; - - // Object type is any JSON type. - @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") - @Nullable private Map options; - - public int id() { - return id; - } - - @NonNull - public String blockexpiry() { - if (blockexpiry != null) - return blockexpiry; - else return ""; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt new file mode 100644 index 000000000..c9182a821 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.wikidata.mwapi + +data class UserInfo( + val name: String = "", + val id: Int = 0, + + //Block information + val blockid: Int = 0, + val blockedby: String? = null, + val blockedbyid: Int = 0, + val blockreason: String? = null, + val blocktimestamp: String? = null, + val blockexpiry: String? = null, + + // Object type is any JSON type. + val options: Map? = null +) { + fun id(): Int = id + + fun blockexpiry(): String = blockexpiry ?: "" +} diff --git a/app/src/main/res/drawable/ic_refresh_24dp_nearby.xml b/app/src/main/res/drawable/ic_refresh_24dp_nearby.xml new file mode 100644 index 000000000..89f49ad9e --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_24dp_nearby.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_custom_selector.xml b/app/src/main/res/layout/activity_custom_selector.xml index 02c864422..44e494cb9 100644 --- a/app/src/main/res/layout/activity_custom_selector.xml +++ b/app/src/main/res/layout/activity_custom_selector.xml @@ -11,7 +11,7 @@ android:id="@+id/partial_access_indicator" android:layout_width="match_parent" android:layout_height="0dp" - app:layout_constraintTop_toBottomOf="@id/toolbar_layout"/> + app:layout_constraintTop_toBottomOf="@id/toolbar_layout" /> + app:srcCompat="@drawable/ic_arrow_back_white" + app:tint="@color/white" /> @@ -69,7 +69,7 @@ android:id="@+id/btn_edit_submit" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_alignParentRight="true" + android:layout_alignParentEnd="true" android:text="@string/submit" android:textColor="@android:color/white" /> diff --git a/app/src/main/res/layout/activity_location_picker.xml b/app/src/main/res/layout/activity_location_picker.xml index a6aa85d71..fa21654b7 100644 --- a/app/src/main/res/layout/activity_location_picker.xml +++ b/app/src/main/res/layout/activity_location_picker.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".LocationPicker.LocationPickerActivity"> + tools:context=".locationpicker.LocationPickerActivity">
+ android:visibility="gone" /> @@ -58,6 +58,7 @@ android:layout_width="@dimen/dimen_0" android:layout_height="wrap_content" android:layout_weight="1" + android:focusable="true" android:padding="@dimen/standard_gap" android:clickable="true" android:background="@drawable/button_background_selector" @@ -69,8 +70,7 @@ android:layout_gravity="center_horizontal" android:duplicateParentState="true" app:srcCompat="@drawable/ic_directions_black_24dp" - android:tint="?attr/rowButtonColor" - /> + app:tint="?attr/rowButtonColor" /> diff --git a/app/src/main/res/layout/bottom_sheet_item_layout.xml b/app/src/main/res/layout/bottom_sheet_item_layout.xml index 4f4c2c854..c569e523a 100644 --- a/app/src/main/res/layout/bottom_sheet_item_layout.xml +++ b/app/src/main/res/layout/bottom_sheet_item_layout.xml @@ -1,11 +1,13 @@ @@ -14,7 +16,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" - android:tint="?attr/rowButtonColor" /> + app:tint="?attr/rowButtonColor" /> + + + + + app:layout_constraintStart_toEndOf="@id/image_limit_error" + app:srcCompat="@drawable/ic_overflow" /> + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_achievements.xml b/app/src/main/res/layout/fragment_achievements.xml index cfd068ea7..00c18b323 100644 --- a/app/src/main/res/layout/fragment_achievements.xml +++ b/app/src/main/res/layout/fragment_achievements.xml @@ -1,677 +1,368 @@ - + android:background="?attr/achievementBackground" + android:fillViewport="true" + tools:ignore="ContentDescription" > - + + + + + + + + android:layout_height="wrap_content" + android:layout_marginEnd="@dimen/activity_margin_horizontal" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@+id/tv_achievements_of_user" + app:srcCompat="@drawable/ic_info_outline_24dp" + app:tint="@color/black" + tools:ignore="ContentDescription" /> - + - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_centerInParent="true" + android:progressDrawable="@android:drawable/progress_horizontal" + android:progressBackgroundTintMode="multiply" + android:progressTint="#5ce65c" + tools:progress="50" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_media_detail.xml b/app/src/main/res/layout/fragment_media_detail.xml index 3c063945d..7ce90d19e 100644 --- a/app/src/main/res/layout/fragment_media_detail.xml +++ b/app/src/main/res/layout/fragment_media_detail.xml @@ -456,6 +456,11 @@ android:layout_height="match_parent" /> + +