mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-30 14:23:55 +01:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
a77870c8ab
1514 changed files with 79859 additions and 76089 deletions
|
|
@ -1,7 +1,6 @@
|
|||
package fr.free.nrw.commons;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
|
|
@ -16,6 +15,7 @@ import androidx.annotation.NonNull;
|
|||
import fr.free.nrw.commons.databinding.ActivityAboutBinding;
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import fr.free.nrw.commons.utils.ConfigUtils;
|
||||
import fr.free.nrw.commons.utils.DialogUtil;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
|
|
@ -64,6 +64,7 @@ public class AboutActivity extends BaseActivity {
|
|||
|
||||
Utils.setUnderlinedText(binding.aboutFaq, R.string.about_faq, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutRateUs, R.string.about_rate_us, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutUserGuide, R.string.user_guide, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutPrivacyPolicy, R.string.about_privacy_policy, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutTranslate, R.string.about_translate, getApplicationContext());
|
||||
Utils.setUnderlinedText(binding.aboutCredits, R.string.about_credits, getApplicationContext());
|
||||
|
|
@ -77,6 +78,7 @@ public class AboutActivity extends BaseActivity {
|
|||
binding.aboutRateUs.setOnClickListener(this::launchRatings);
|
||||
binding.aboutCredits.setOnClickListener(this::launchCredits);
|
||||
binding.aboutPrivacyPolicy.setOnClickListener(this::launchPrivacyPolicy);
|
||||
binding.aboutUserGuide.setOnClickListener(this::launchUserGuide);
|
||||
binding.aboutFaq.setOnClickListener(this::launchFrequentlyAskedQuesions);
|
||||
binding.aboutTranslate.setOnClickListener(this::launchTranslate);
|
||||
}
|
||||
|
|
@ -99,7 +101,14 @@ public class AboutActivity extends BaseActivity {
|
|||
}
|
||||
|
||||
public void launchGithub(View view) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL));
|
||||
Intent intent;
|
||||
try {
|
||||
intent = new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.GITHUB_REPO_URL));
|
||||
intent.setPackage(Urls.GITHUB_PACKAGE_NAME);
|
||||
startActivity(intent);
|
||||
} catch (Exception e) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL));
|
||||
}
|
||||
}
|
||||
|
||||
public void launchWebsite(View view) {
|
||||
|
|
@ -114,6 +123,10 @@ public class AboutActivity extends BaseActivity {
|
|||
Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL));
|
||||
}
|
||||
|
||||
public void launchUserGuide(View view) {
|
||||
Utils.handleWebUrl(this, Uri.parse(Urls.USER_GUIDE_URL));
|
||||
}
|
||||
|
||||
public void launchPrivacyPolicy(View view) {
|
||||
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
|
||||
}
|
||||
|
|
@ -155,17 +168,20 @@ public class AboutActivity extends BaseActivity {
|
|||
spinner.setAdapter(languageAdapter);
|
||||
spinner.setGravity(17);
|
||||
spinner.setPadding(50,0,0,0);
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(AboutActivity.this);
|
||||
builder.setView(spinner);
|
||||
builder.setTitle(R.string.about_translate_title)
|
||||
.setMessage(R.string.about_translate_message)
|
||||
.setPositiveButton(R.string.about_translate_proceed, (dialog, which) -> {
|
||||
String langCode = CommonsApplication.getInstance().getLanguageLookUpTable().getCodes().get(spinner.getSelectedItemPosition());
|
||||
Utils.handleWebUrl(AboutActivity.this, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode));
|
||||
});
|
||||
builder.setNegativeButton(R.string.about_translate_cancel, (dialog, which) -> dialog.cancel());
|
||||
builder.create().show();
|
||||
|
||||
Runnable positiveButtonRunnable = () -> {
|
||||
String langCode = CommonsApplication.getInstance().getLanguageLookUpTable().getCodes().get(spinner.getSelectedItemPosition());
|
||||
Utils.handleWebUrl(AboutActivity.this, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode));
|
||||
};
|
||||
DialogUtil.showAlertDialog(this,
|
||||
getString(R.string.about_translate_title),
|
||||
getString(R.string.about_translate_message),
|
||||
getString(R.string.about_translate_proceed),
|
||||
getString(R.string.about_translate_cancel),
|
||||
positiveButtonRunnable,
|
||||
() -> {},
|
||||
spinner
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
63
app/src/main/java/fr/free/nrw/commons/BaseMarker.kt
Normal file
63
app/src/main/java/fr/free/nrw/commons/BaseMarker.kt
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package fr.free.nrw.commons
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import fr.free.nrw.commons.location.LatLng
|
||||
import fr.free.nrw.commons.nearby.Place
|
||||
|
||||
class BaseMarker {
|
||||
private var _position: LatLng = LatLng(0.0, 0.0, 0f)
|
||||
private var _title: String = ""
|
||||
private var _place: Place = Place()
|
||||
private var _icon: Bitmap? = null
|
||||
|
||||
var position: LatLng
|
||||
get() = _position
|
||||
set(value) {
|
||||
_position = value
|
||||
}
|
||||
var title: String
|
||||
get() = _title
|
||||
set(value) {
|
||||
_title = value
|
||||
}
|
||||
|
||||
var place: Place
|
||||
get() = _place
|
||||
set(value) {
|
||||
_place = value
|
||||
}
|
||||
var icon: Bitmap?
|
||||
get() = _icon
|
||||
set(value) {
|
||||
_icon = value
|
||||
}
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
fun fromResource(
|
||||
context: Context,
|
||||
drawableResId: Int,
|
||||
) {
|
||||
val drawable: Drawable = context.resources.getDrawable(drawableResId)
|
||||
icon =
|
||||
if (drawable is BitmapDrawable) {
|
||||
drawable.bitmap
|
||||
} else {
|
||||
val bitmap =
|
||||
Bitmap.createBitmap(
|
||||
drawable.intrinsicWidth,
|
||||
drawable.intrinsicHeight,
|
||||
Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
val canvas = Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||
drawable.draw(canvas)
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,10 @@ object BetaConstants {
|
|||
* production server where beta server does not work
|
||||
*/
|
||||
const val COMMONS_URL = "https://commons.wikimedia.org/"
|
||||
|
||||
/**
|
||||
* Commons production's depicts property which is used in beta for some specific GET calls on
|
||||
* production server where beta server does not work
|
||||
*/
|
||||
const val DEPICTS_PROPERTY = "P180"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
app/src/main/java/fr/free/nrw/commons/CameraPosition.kt
Normal file
33
app/src/main/java/fr/free/nrw/commons/CameraPosition.kt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package fr.free.nrw.commons
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
|
||||
class CameraPosition(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val zoom: Double,
|
||||
) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readDouble(),
|
||||
parcel.readDouble(),
|
||||
parcel.readDouble(),
|
||||
)
|
||||
|
||||
override fun writeToParcel(
|
||||
parcel: Parcel,
|
||||
flags: Int,
|
||||
) {
|
||||
parcel.writeDouble(latitude)
|
||||
parcel.writeDouble(longitude)
|
||||
parcel.writeDouble(zoom)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<CameraPosition> {
|
||||
override fun createFromParcel(parcel: Parcel): CameraPosition = CameraPosition(parcel)
|
||||
|
||||
override fun newArray(size: Int): Array<CameraPosition?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
package fr.free.nrw.commons;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.wikipedia.AppAdapter;
|
||||
import org.wikipedia.dataclient.SharedPreferenceCookieManager;
|
||||
import org.wikipedia.dataclient.WikiSite;
|
||||
import org.wikipedia.json.GsonMarshaller;
|
||||
import org.wikipedia.json.GsonUnmarshaller;
|
||||
import org.wikipedia.login.LoginResult;
|
||||
|
||||
import fr.free.nrw.commons.auth.SessionManager;
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
||||
import okhttp3.OkHttpClient;
|
||||
|
||||
public class CommonsAppAdapter extends AppAdapter {
|
||||
private final int DEFAULT_THUMB_SIZE = 640;
|
||||
private final String COOKIE_STORE_NAME = "cookie_store";
|
||||
|
||||
private final SessionManager sessionManager;
|
||||
private final JsonKvStore preferences;
|
||||
|
||||
CommonsAppAdapter(@NonNull SessionManager sessionManager, @NonNull JsonKvStore preferences) {
|
||||
this.sessionManager = sessionManager;
|
||||
this.preferences = preferences;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMediaWikiBaseUrl() {
|
||||
return BuildConfig.COMMONS_URL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRestbaseUriFormat() {
|
||||
return BuildConfig.COMMONS_URL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OkHttpClient getOkHttpClient(@NonNull WikiSite wikiSite) {
|
||||
return OkHttpConnectionFactory.getClient();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDesiredLeadImageDp() {
|
||||
return DEFAULT_THUMB_SIZE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLoggedIn() {
|
||||
return sessionManager.isUserLoggedIn();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUserName() {
|
||||
return sessionManager.getUserName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return sessionManager.getPassword();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateAccount(@NonNull LoginResult result) {
|
||||
sessionManager.updateAccount(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferenceCookieManager getCookies() {
|
||||
if (!preferences.contains(COOKIE_STORE_NAME)) {
|
||||
return null;
|
||||
}
|
||||
return GsonUnmarshaller.unmarshal(SharedPreferenceCookieManager.class,
|
||||
preferences.getString(COOKIE_STORE_NAME, null));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCookies(@NonNull SharedPreferenceCookieManager cookies) {
|
||||
preferences.putString(COOKIE_STORE_NAME, GsonMarshaller.marshal(cookies));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean logErrorsInsteadOfCrashing() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,365 +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.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
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.BuildConfig;
|
||||
import androidx.multidex.MultiDexApplication;
|
||||
import com.facebook.drawee.backends.pipeline.Fresco;
|
||||
import com.facebook.imagepipeline.core.ImagePipeline;
|
||||
import com.facebook.imagepipeline.core.ImagePipelineConfig;
|
||||
import com.mapbox.mapboxsdk.Mapbox;
|
||||
import com.squareup.leakcanary.LeakCanary;
|
||||
import com.squareup.leakcanary.RefWatcher;
|
||||
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.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 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 org.wikipedia.AppAdapter;
|
||||
import org.wikipedia.language.AppLanguageLookUpTable;
|
||||
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 IS_LIMITED_CONNECTION_MODE_ENABLED = "is_limited_connection_mode_enabled";
|
||||
@Inject
|
||||
SessionManager sessionManager;
|
||||
@Inject
|
||||
DBOpenHelper dbOpenHelper;
|
||||
|
||||
@Inject
|
||||
@Named("default_preferences")
|
||||
JsonKvStore defaultPrefs;
|
||||
|
||||
@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 NOTIFICATION_CHANNEL_ID_ALL = "CommonsNotificationAll";
|
||||
|
||||
public static final String FEEDBACK_EMAIL_TEMPLATE_HEADER = "-- Technical information --";
|
||||
|
||||
/**
|
||||
* Constants End
|
||||
*/
|
||||
|
||||
private RefWatcher refWatcher;
|
||||
|
||||
private static CommonsApplication INSTANCE;
|
||||
|
||||
public static CommonsApplication getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private AppLanguageLookUpTable languageLookUpTable;
|
||||
|
||||
public AppLanguageLookUpTable getLanguageLookUpTable() {
|
||||
return languageLookUpTable;
|
||||
}
|
||||
|
||||
@Inject
|
||||
ContributionDao contributionDao;
|
||||
|
||||
/**
|
||||
* In-memory list of contributions whose uploads have been paused by the user
|
||||
*/
|
||||
public static Map<String, Boolean> pauseUploads = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Used to declare and initialize various components and dependencies
|
||||
*/
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
INSTANCE = this;
|
||||
ACRA.init(this);
|
||||
Mapbox.getInstance(this, getString(R.string.mapbox_commons_app_token));
|
||||
|
||||
ApplicationlessInjection
|
||||
.getInstance(this)
|
||||
.getCommonsApplicationComponent()
|
||||
.inject(this);
|
||||
|
||||
AppAdapter.set(new CommonsAppAdapter(sessionManager, defaultPrefs));
|
||||
|
||||
initTimber();
|
||||
|
||||
if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
|
||||
Set<String> defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS);
|
||||
if (null == defaultExifTagsSet) {
|
||||
defaultExifTagsSet = new HashSet<>();
|
||||
}
|
||||
defaultExifTagsSet.add(getString(R.string.exif_tag_location));
|
||||
defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet);
|
||||
}
|
||||
|
||||
// Set DownsampleEnabled to True to downsample the image in case it's heavy
|
||||
ImagePipelineConfig config = ImagePipelineConfig.newBuilder(this)
|
||||
.setNetworkFetcher(customOkHttpNetworkFetcher)
|
||||
.setDownsampleEnabled(true)
|
||||
.build();
|
||||
try {
|
||||
Fresco.initialize(this, config);
|
||||
} catch (Exception e) {
|
||||
Timber.e(e);
|
||||
// TODO: Remove when we're able to initialize Fresco in test builds.
|
||||
}
|
||||
|
||||
createNotificationChannel(this);
|
||||
|
||||
languageLookUpTable = new AppLanguageLookUpTable(this);
|
||||
|
||||
// This handler will catch exceptions thrown from Observables after they are disposed,
|
||||
// or from Observables that are (deliberately or not) missing an onError handler.
|
||||
RxJavaPlugins.setErrorHandler(Functions.emptyConsumer());
|
||||
|
||||
if (setupLeakCanary() == RefWatcher.DISABLED) {
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helps in setting up LeakCanary library
|
||||
*
|
||||
* @return instance of LeakCanary
|
||||
*/
|
||||
protected RefWatcher setupLeakCanary() {
|
||||
if (LeakCanary.isInAnalyzerProcess(this)) {
|
||||
return RefWatcher.DISABLED;
|
||||
}
|
||||
return LeakCanary.install(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a way to get member refWatcher
|
||||
*
|
||||
* @param context Application context
|
||||
* @return application member refWatcher
|
||||
*/
|
||||
public static RefWatcher getRefWatcher(Context context) {
|
||||
CommonsApplication application = (CommonsApplication) context.getApplicationContext();
|
||||
return application.refWatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(() -> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
414
app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
Normal file
414
app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
package fr.free.nrw.commons
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import androidx.multidex.MultiDexApplication
|
||||
import com.facebook.drawee.backends.pipeline.Fresco
|
||||
import com.facebook.imagepipeline.core.ImagePipelineConfig
|
||||
import fr.free.nrw.commons.auth.LoginActivity
|
||||
import fr.free.nrw.commons.auth.SessionManager
|
||||
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsDao
|
||||
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
|
||||
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao
|
||||
import fr.free.nrw.commons.category.CategoryDao
|
||||
import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler
|
||||
import fr.free.nrw.commons.concurrency.ThreadPoolService
|
||||
import fr.free.nrw.commons.contributions.ContributionDao
|
||||
import fr.free.nrw.commons.data.DBOpenHelper
|
||||
import fr.free.nrw.commons.di.ApplicationlessInjection
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore
|
||||
import fr.free.nrw.commons.language.AppLanguageLookUpTable
|
||||
import fr.free.nrw.commons.logging.FileLoggingTree
|
||||
import fr.free.nrw.commons.logging.LogUtils
|
||||
import fr.free.nrw.commons.media.CustomOkHttpNetworkFetcher
|
||||
import fr.free.nrw.commons.settings.Prefs
|
||||
import fr.free.nrw.commons.upload.FileUtils
|
||||
import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
|
||||
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
|
||||
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.internal.functions.Functions
|
||||
import io.reactivex.plugins.RxJavaPlugins
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import org.acra.ACRA.init
|
||||
import org.acra.ReportField
|
||||
import org.acra.annotation.AcraCore
|
||||
import org.acra.annotation.AcraDialog
|
||||
import org.acra.annotation.AcraMailSender
|
||||
import org.acra.data.StringFormat
|
||||
import timber.log.Timber
|
||||
import timber.log.Timber.DebugTree
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
||||
@AcraCore(
|
||||
buildConfigClass = BuildConfig::class,
|
||||
resReportSendSuccessToast = R.string.crash_dialog_ok_toast,
|
||||
reportFormat = StringFormat.KEY_VALUE_LIST,
|
||||
reportContent = [ReportField.USER_COMMENT, ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_NAME, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, ReportField.STACK_TRACE]
|
||||
)
|
||||
|
||||
@AcraMailSender(mailTo = "commons-app-android-private@googlegroups.com", reportAsFile = false)
|
||||
|
||||
@AcraDialog(
|
||||
resTheme = R.style.Theme_AppCompat_Dialog,
|
||||
resText = R.string.crash_dialog_text,
|
||||
resTitle = R.string.crash_dialog_title,
|
||||
resCommentPrompt = R.string.crash_dialog_comment_prompt
|
||||
)
|
||||
|
||||
class CommonsApplication : MultiDexApplication() {
|
||||
|
||||
@Inject
|
||||
lateinit var sessionManager: SessionManager
|
||||
|
||||
@Inject
|
||||
lateinit var dbOpenHelper: DBOpenHelper
|
||||
|
||||
@Inject
|
||||
@field:Named("default_preferences")
|
||||
lateinit var defaultPrefs: JsonKvStore
|
||||
|
||||
@Inject
|
||||
lateinit var cookieJar: CommonsCookieJar
|
||||
|
||||
@Inject
|
||||
lateinit var customOkHttpNetworkFetcher: CustomOkHttpNetworkFetcher
|
||||
|
||||
var languageLookUpTable: AppLanguageLookUpTable? = null
|
||||
private set
|
||||
|
||||
@Inject
|
||||
lateinit var contributionDao: ContributionDao
|
||||
|
||||
/**
|
||||
* Used to declare and initialize various components and dependencies
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
instance = this
|
||||
init(this)
|
||||
|
||||
ApplicationlessInjection
|
||||
.getInstance(this)
|
||||
.commonsApplicationComponent
|
||||
.inject(this)
|
||||
|
||||
initTimber()
|
||||
|
||||
if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
|
||||
var defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS)
|
||||
if (null == defaultExifTagsSet) {
|
||||
defaultExifTagsSet = HashSet()
|
||||
}
|
||||
defaultExifTagsSet.add(getString(R.string.exif_tag_location))
|
||||
defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet)
|
||||
}
|
||||
|
||||
// Set DownsampleEnabled to True to downsample the image in case it's heavy
|
||||
val config = ImagePipelineConfig.newBuilder(this)
|
||||
.setNetworkFetcher(customOkHttpNetworkFetcher)
|
||||
.setDownsampleEnabled(true)
|
||||
.build()
|
||||
try {
|
||||
Fresco.initialize(this, config)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
// TODO: Remove when we're able to initialize Fresco in test builds.
|
||||
}
|
||||
|
||||
createNotificationChannel(this)
|
||||
|
||||
languageLookUpTable = AppLanguageLookUpTable(this)
|
||||
|
||||
// This handler will catch exceptions thrown from Observables after they are disposed,
|
||||
// or from Observables that are (deliberately or not) missing an onError handler.
|
||||
RxJavaPlugins.setErrorHandler(Functions.emptyConsumer())
|
||||
|
||||
// Fire progress callbacks for every 3% of uploaded content
|
||||
System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0")
|
||||
}
|
||||
|
||||
/**
|
||||
* Plants debug and file logging tree. Timber lets you plant your own logging trees.
|
||||
*/
|
||||
private fun initTimber() {
|
||||
val isBeta = isBetaFlavour
|
||||
val logFileName =
|
||||
if (isBeta) "CommonsBetaAppLogs" else "CommonsAppLogs"
|
||||
val logDirectory = LogUtils.getLogDirectory()
|
||||
//Delete stale logs if they have exceeded the specified size
|
||||
deleteStaleLogs(logFileName, logDirectory)
|
||||
|
||||
val tree = FileLoggingTree(
|
||||
Log.VERBOSE,
|
||||
logFileName,
|
||||
logDirectory,
|
||||
1000,
|
||||
fileLoggingThreadPool
|
||||
)
|
||||
|
||||
Timber.plant(tree)
|
||||
Timber.plant(DebugTree())
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the logs zip file at the specified directory and file locations specified in the
|
||||
* params
|
||||
*
|
||||
* @param logFileName
|
||||
* @param logDirectory
|
||||
*/
|
||||
private fun deleteStaleLogs(logFileName: String, logDirectory: String) {
|
||||
try {
|
||||
val file = File("$logDirectory/zip/$logFileName.zip")
|
||||
if (file.exists() && file.totalSpace > 1000000) { // In Kbs
|
||||
file.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
|
||||
private val fileLoggingThreadPool: ThreadPoolService
|
||||
get() = ThreadPoolService.Builder("file-logging-thread")
|
||||
.setPriority(Process.THREAD_PRIORITY_LOWEST)
|
||||
.setPoolSize(1)
|
||||
.setExceptionHandler(BackgroundPoolExceptionHandler())
|
||||
.build()
|
||||
|
||||
val userAgent: String
|
||||
get() = ("Commons/" + this.getVersionNameWithSha()
|
||||
+ " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE)
|
||||
|
||||
/**
|
||||
* clears data of current application
|
||||
*
|
||||
* @param context Application context
|
||||
* @param logoutListener Implementation of interface LogoutListener
|
||||
*/
|
||||
@SuppressLint("CheckResult")
|
||||
fun clearApplicationData(context: Context, logoutListener: LogoutListener) {
|
||||
val cacheDirectory = context.cacheDir
|
||||
val applicationDirectory = File(cacheDirectory.parent)
|
||||
if (applicationDirectory.exists()) {
|
||||
val fileNames = applicationDirectory.list()
|
||||
for (fileName in fileNames) {
|
||||
if (fileName != "lib") {
|
||||
FileUtils.deleteFile(File(applicationDirectory, fileName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionManager.logout()
|
||||
.andThen(Completable.fromAction { cookieJar.clear() })
|
||||
.andThen(Completable.fromAction {
|
||||
Timber.d("All accounts have been removed")
|
||||
clearImageCache()
|
||||
//TODO: fix preference manager
|
||||
defaultPrefs.clearAll()
|
||||
defaultPrefs.putBoolean("firstrun", false)
|
||||
updateAllDatabases()
|
||||
})
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ logoutListener.onLogoutComplete() }, { t: Throwable? -> Timber.e(t) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all images cache held by Fresco
|
||||
*/
|
||||
private fun clearImageCache() {
|
||||
val imagePipeline = Fresco.getImagePipeline()
|
||||
imagePipeline.clearCaches()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all tables and re-creates them.
|
||||
*/
|
||||
private fun updateAllDatabases() {
|
||||
dbOpenHelper.readableDatabase.close()
|
||||
val db = dbOpenHelper.writableDatabase
|
||||
|
||||
CategoryDao.Table.onDelete(db)
|
||||
dbOpenHelper.deleteTable(
|
||||
db,
|
||||
DBOpenHelper.CONTRIBUTIONS_TABLE
|
||||
) //Delete the contributions table in the existing db on older versions
|
||||
|
||||
try {
|
||||
contributionDao.deleteAll()
|
||||
} catch (e: SQLiteException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
BookmarkPicturesDao.Table.onDelete(db)
|
||||
BookmarkLocationsDao.Table.onDelete(db)
|
||||
BookmarkItemsDao.Table.onDelete(db)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Interface used to get log-out events
|
||||
*/
|
||||
interface LogoutListener {
|
||||
fun onLogoutComplete()
|
||||
}
|
||||
|
||||
/**
|
||||
* This listener is responsible for handling post-logout actions, specifically invoking the LoginActivity
|
||||
* with relevant intent parameters. It does not perform the actual logout operation.
|
||||
*/
|
||||
open class BaseLogoutListener : LogoutListener {
|
||||
var ctx: Context
|
||||
var loginMessage: String? = null
|
||||
var userName: String? = null
|
||||
|
||||
/**
|
||||
* Constructor for BaseLogoutListener.
|
||||
*
|
||||
* @param ctx Application context
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor for BaseLogoutListener
|
||||
*
|
||||
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
|
||||
* @param loginMessage Message to be displayed on the login page
|
||||
* @param loginUsername Username to be pre-filled on the login page
|
||||
*/
|
||||
constructor(
|
||||
ctx: Context, loginMessage: String?,
|
||||
loginUsername: String?
|
||||
) {
|
||||
this.ctx = ctx
|
||||
this.loginMessage = loginMessage
|
||||
this.userName = loginUsername
|
||||
}
|
||||
|
||||
override fun onLogoutComplete() {
|
||||
Timber.d("Logout complete callback received.")
|
||||
val loginIntent = Intent(ctx, LoginActivity::class.java)
|
||||
loginIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
if (loginMessage != null) {
|
||||
loginIntent.putExtra(LOGIN_MESSAGE_INTENT_KEY, loginMessage)
|
||||
}
|
||||
if (userName != null) {
|
||||
loginIntent.putExtra(LOGIN_USERNAME_INTENT_KEY, userName)
|
||||
}
|
||||
|
||||
ctx.startActivity(loginIntent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is an extension of BaseLogoutListener, providing additional functionality or customization
|
||||
* for the logout process. It includes specific actions to be taken during logout, such as handling redirection to the login screen.
|
||||
*/
|
||||
class ActivityLogoutListener : BaseLogoutListener {
|
||||
var activity: Activity
|
||||
|
||||
|
||||
/**
|
||||
* Constructor for ActivityLogoutListener.
|
||||
*
|
||||
* @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
|
||||
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
|
||||
*/
|
||||
constructor(activity: Activity, ctx: Context) : super(ctx) {
|
||||
this.activity = activity
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor for ActivityLogoutListener with additional parameters for the login screen.
|
||||
*
|
||||
* @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
|
||||
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
|
||||
* @param loginMessage Message to be displayed on the login page after logout.
|
||||
* @param loginUsername Username to be pre-filled on the login page after logout.
|
||||
*/
|
||||
constructor(
|
||||
activity: Activity, ctx: Context?,
|
||||
loginMessage: String?, loginUsername: String?
|
||||
) : super(activity, loginMessage, loginUsername) {
|
||||
this.activity = activity
|
||||
}
|
||||
|
||||
override fun onLogoutComplete() {
|
||||
super.onLogoutComplete()
|
||||
activity.finish()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val LOGIN_MESSAGE_INTENT_KEY: String = "loginMessage"
|
||||
const val LOGIN_USERNAME_INTENT_KEY: String = "loginUsername"
|
||||
|
||||
const val IS_LIMITED_CONNECTION_MODE_ENABLED: String = "is_limited_connection_mode_enabled"
|
||||
|
||||
/**
|
||||
* Constants begin
|
||||
*/
|
||||
const val OPEN_APPLICATION_DETAIL_SETTINGS: Int = 1001
|
||||
|
||||
const val DEFAULT_EDIT_SUMMARY: String = "Uploaded using [[COM:MOA|Commons Mobile App]]"
|
||||
|
||||
const val FEEDBACK_EMAIL: String = "commons-app-android@googlegroups.com"
|
||||
|
||||
const val FEEDBACK_EMAIL_SUBJECT: String = "Commons Android App Feedback"
|
||||
|
||||
const val REPORT_EMAIL: String = "commons-app-android-private@googlegroups.com"
|
||||
|
||||
const val REPORT_EMAIL_SUBJECT: String = "Report a violation"
|
||||
|
||||
const val NOTIFICATION_CHANNEL_ID_ALL: String = "CommonsNotificationAll"
|
||||
|
||||
const val FEEDBACK_EMAIL_TEMPLATE_HEADER: String = "-- Technical information --"
|
||||
|
||||
/**
|
||||
* Constants End
|
||||
*/
|
||||
|
||||
@JvmStatic
|
||||
lateinit var instance: CommonsApplication
|
||||
private set
|
||||
|
||||
@JvmField
|
||||
var isPaused: Boolean = false
|
||||
|
||||
@JvmStatic
|
||||
fun createNotificationChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = context
|
||||
.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
var channel = manager
|
||||
.getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL)
|
||||
if (channel == null) {
|
||||
channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID_ALL,
|
||||
context.getString(R.string.notifications_channel_name_all),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package fr.free.nrw.commons.LocationPicker;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import com.mapbox.mapboxsdk.camera.CameraPosition;
|
||||
|
||||
/**
|
||||
* 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 sets the activity
|
||||
* @param activity Activity
|
||||
* @return Intent
|
||||
*/
|
||||
public Intent build(final Activity activity) {
|
||||
intent.setClass(activity, LocationPickerActivity.class);
|
||||
return intent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,450 +0,0 @@
|
|||
package fr.free.nrw.commons.LocationPicker;
|
||||
|
||||
import static com.mapbox.mapboxsdk.style.layers.Property.NONE;
|
||||
import static com.mapbox.mapboxsdk.style.layers.Property.VISIBLE;
|
||||
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconAllowOverlap;
|
||||
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconIgnorePlacement;
|
||||
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.iconImage;
|
||||
import static com.mapbox.mapboxsdk.style.layers.PropertyFactory.visibility;
|
||||
import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION;
|
||||
import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Bundle;
|
||||
import android.text.Html;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
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 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.lifecycle.Observer;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
import com.mapbox.android.core.permissions.PermissionsManager;
|
||||
import com.mapbox.geojson.Point;
|
||||
import com.mapbox.mapboxsdk.camera.CameraPosition;
|
||||
import com.mapbox.mapboxsdk.camera.CameraPosition.Builder;
|
||||
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory;
|
||||
import com.mapbox.mapboxsdk.geometry.LatLng;
|
||||
import com.mapbox.mapboxsdk.location.LocationComponent;
|
||||
import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions;
|
||||
import com.mapbox.mapboxsdk.location.modes.CameraMode;
|
||||
import com.mapbox.mapboxsdk.location.modes.RenderMode;
|
||||
import com.mapbox.mapboxsdk.maps.MapView;
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap;
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraIdleListener;
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener;
|
||||
import com.mapbox.mapboxsdk.maps.OnMapReadyCallback;
|
||||
import com.mapbox.mapboxsdk.maps.Style;
|
||||
import com.mapbox.mapboxsdk.maps.UiSettings;
|
||||
import com.mapbox.mapboxsdk.style.layers.Layer;
|
||||
import com.mapbox.mapboxsdk.style.layers.SymbolLayer;
|
||||
import com.mapbox.mapboxsdk.style.sources.GeoJsonSource;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.Utils;
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* Helps to pick location and return the result with an intent
|
||||
*/
|
||||
public class LocationPickerActivity extends BaseActivity implements OnMapReadyCallback,
|
||||
OnCameraMoveStartedListener, OnCameraIdleListener, Observer<CameraPosition> {
|
||||
|
||||
/**
|
||||
* DROPPED_MARKER_LAYER_ID : id for layer
|
||||
*/
|
||||
private static final String DROPPED_MARKER_LAYER_ID = "DROPPED_MARKER_LAYER_ID";
|
||||
/**
|
||||
* cameraPosition : position of picker
|
||||
*/
|
||||
private CameraPosition cameraPosition;
|
||||
/**
|
||||
* markerImage : picker image
|
||||
*/
|
||||
private ImageView markerImage;
|
||||
/**
|
||||
* mapboxMap : map
|
||||
*/
|
||||
private MapboxMap mapboxMap;
|
||||
/**
|
||||
* mapView : view of the map
|
||||
*/
|
||||
private MapView mapView;
|
||||
/**
|
||||
* tvAttribution : credit
|
||||
*/
|
||||
private AppCompatTextView tvAttribution;
|
||||
/**
|
||||
* activity : activity key
|
||||
*/
|
||||
private String activity;
|
||||
/**
|
||||
* modifyLocationButton : button for start editing location
|
||||
*/
|
||||
Button modifyLocationButton;
|
||||
/**
|
||||
* showInMapButton : button for showing in map
|
||||
*/
|
||||
TextView showInMapButton;
|
||||
/**
|
||||
* placeSelectedButton : fab for selecting location
|
||||
*/
|
||||
FloatingActionButton placeSelectedButton;
|
||||
/**
|
||||
* droppedMarkerLayer : Layer for static screen
|
||||
*/
|
||||
private Layer droppedMarkerLayer;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
final LocationPickerViewModel viewModel = new ViewModelProvider(this)
|
||||
.get(LocationPickerViewModel.class);
|
||||
viewModel.getResult().observe(this, this);
|
||||
|
||||
bindViews();
|
||||
addBackButtonListener();
|
||||
addPlaceSelectedButton();
|
||||
addCredits();
|
||||
getToolbarUI();
|
||||
|
||||
if (activity.equals("UploadActivity")) {
|
||||
placeSelectedButton.setVisibility(View.GONE);
|
||||
modifyLocationButton.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));
|
||||
}
|
||||
|
||||
mapView.onCreate(savedInstanceState);
|
||||
mapView.getMapAsync(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* For showing credits
|
||||
*/
|
||||
private void addCredits() {
|
||||
tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution)));
|
||||
tvAttribution.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicking back button destroy locationPickerActivity
|
||||
*/
|
||||
private void addBackButtonListener() {
|
||||
final ImageView backButton = findViewById(R.id.mapbox_place_picker_toolbar_back_button);
|
||||
backButton.setOnClickListener(view -> 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);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the listeners
|
||||
*/
|
||||
private void bindListeners() {
|
||||
mapboxMap.addOnCameraMoveStartedListener(
|
||||
this);
|
||||
mapboxMap.addOnCameraIdleListener(
|
||||
this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes action when map is ready to show
|
||||
* @param mapboxMap map
|
||||
*/
|
||||
@Override
|
||||
public void onMapReady(final MapboxMap mapboxMap) {
|
||||
this.mapboxMap = mapboxMap;
|
||||
mapboxMap.setStyle(Style.MAPBOX_STREETS, this::onStyleLoaded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes dropped marker and layer
|
||||
* Handles camera position based on options
|
||||
* Enables location components
|
||||
*
|
||||
* @param style style
|
||||
*/
|
||||
private void onStyleLoaded(final Style style) {
|
||||
if (modifyLocationButton.getVisibility() == View.VISIBLE) {
|
||||
initDroppedMarker(style);
|
||||
adjustCameraBasedOnOptions();
|
||||
enableLocationComponent(style);
|
||||
if (style.getLayer(DROPPED_MARKER_LAYER_ID) != null) {
|
||||
final GeoJsonSource source = style.getSourceAs("dropped-marker-source-id");
|
||||
if (source != null) {
|
||||
source.setGeoJson(Point.fromLngLat(cameraPosition.target.getLongitude(),
|
||||
cameraPosition.target.getLatitude()));
|
||||
}
|
||||
droppedMarkerLayer = style.getLayer(DROPPED_MARKER_LAYER_ID);
|
||||
if (droppedMarkerLayer != null) {
|
||||
droppedMarkerLayer.setProperties(visibility(VISIBLE));
|
||||
markerImage.setVisibility(View.GONE);
|
||||
shadow.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
adjustCameraBasedOnOptions();
|
||||
enableLocationComponent(style);
|
||||
bindListeners();
|
||||
}
|
||||
|
||||
modifyLocationButton.setOnClickListener(v -> onClickModifyLocation());
|
||||
showInMapButton.setOnClickListener(v -> showInMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles onclick event of modifyLocationButton
|
||||
*/
|
||||
private void onClickModifyLocation() {
|
||||
placeSelectedButton.setVisibility(View.VISIBLE);
|
||||
modifyLocationButton.setVisibility(View.GONE);
|
||||
showInMapButton.setVisibility(View.GONE);
|
||||
droppedMarkerLayer.setProperties(visibility(NONE));
|
||||
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));
|
||||
bindListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the location in map app
|
||||
*/
|
||||
public void showInMap(){
|
||||
Utils.handleGeoCoordinates(this,
|
||||
new fr.free.nrw.commons.location.LatLng(cameraPosition.target.getLatitude(),
|
||||
cameraPosition.target.getLongitude(), 0.0f));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Dropped Marker and layer without showing
|
||||
* @param loadedMapStyle style
|
||||
*/
|
||||
private void initDroppedMarker(@NonNull final Style loadedMapStyle) {
|
||||
// Add the marker image to map
|
||||
loadedMapStyle.addImage("dropped-icon-image", BitmapFactory.decodeResource(
|
||||
getResources(), R.drawable.map_default_map_marker));
|
||||
loadedMapStyle.addSource(new GeoJsonSource("dropped-marker-source-id"));
|
||||
loadedMapStyle.addLayer(new SymbolLayer(DROPPED_MARKER_LAYER_ID,
|
||||
"dropped-marker-source-id").withProperties(
|
||||
iconImage("dropped-icon-image"),
|
||||
visibility(NONE),
|
||||
iconAllowOverlap(true),
|
||||
iconIgnorePlacement(true)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* move the location to the current media coordinates
|
||||
*/
|
||||
private void adjustCameraBasedOnOptions() {
|
||||
mapboxMap.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables location components
|
||||
* @param loadedMapStyle Style
|
||||
*/
|
||||
@SuppressWarnings( {"MissingPermission"})
|
||||
private void enableLocationComponent(@NonNull final Style loadedMapStyle) {
|
||||
final UiSettings uiSettings = mapboxMap.getUiSettings();
|
||||
uiSettings.setAttributionEnabled(false);
|
||||
|
||||
// Check if permissions are enabled and if not request
|
||||
if (PermissionsManager.areLocationPermissionsGranted(this)) {
|
||||
|
||||
// Get an instance of the component
|
||||
final LocationComponent locationComponent = mapboxMap.getLocationComponent();
|
||||
|
||||
// Activate with options
|
||||
locationComponent.activateLocationComponent(
|
||||
LocationComponentActivationOptions.builder(this, loadedMapStyle).build());
|
||||
|
||||
// Enable to make component visible
|
||||
locationComponent.setLocationComponentEnabled(true);
|
||||
|
||||
// Set the component's camera mode
|
||||
locationComponent.setCameraMode(CameraMode.NONE);
|
||||
|
||||
// Set the component's render mode
|
||||
locationComponent.setRenderMode(RenderMode.NORMAL);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acts on camera moving
|
||||
* @param reason int
|
||||
*/
|
||||
@Override
|
||||
public void onCameraMoveStarted(final int reason) {
|
||||
Timber.v("Map camera has begun moving.");
|
||||
if (markerImage.getTranslationY() == 0) {
|
||||
markerImage.animate().translationY(-75)
|
||||
.setInterpolator(new OvershootInterpolator()).setDuration(250).start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acts on camera idle
|
||||
*/
|
||||
@Override
|
||||
public void onCameraIdle() {
|
||||
Timber.v("Map camera is now idling.");
|
||||
markerImage.animate().translationY(0)
|
||||
.setInterpolator(new OvershootInterpolator()).setDuration(250).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes action on camera position
|
||||
* @param position position of picker
|
||||
*/
|
||||
@Override
|
||||
public void onChanged(@Nullable CameraPosition position) {
|
||||
if (position == null) {
|
||||
position = new Builder()
|
||||
.target(new LatLng(mapboxMap.getCameraPosition().target.getLatitude(),
|
||||
mapboxMap.getCameraPosition().target.getLongitude()))
|
||||
.zoom(16).build();
|
||||
}
|
||||
cameraPosition = position;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
mapboxMap.getCameraPosition().target.getLatitude()
|
||||
+ ","
|
||||
+ mapboxMap.getCameraPosition().target.getLongitude());
|
||||
applicationKvStore.putString(LAST_ZOOM, mapboxMap.getCameraPosition().zoom + "");
|
||||
}
|
||||
final Intent returningIntent = new Intent();
|
||||
returningIntent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION,
|
||||
mapboxMap.getCameraPosition());
|
||||
setResult(AppCompatActivity.RESULT_OK, returningIntent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
mapView.onStart();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
mapView.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
mapView.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
mapView.onStop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(final @NotNull Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
mapView.onSaveInstanceState(outState);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
mapView.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLowMemory() {
|
||||
super.onLowMemory();
|
||||
mapView.onLowMemory();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +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";
|
||||
|
||||
|
||||
private LocationPickerConstants() {
|
||||
}
|
||||
}
|
||||
|
|
@ -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 com.mapbox.mapboxsdk.camera.CameraPosition;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* Observes live camera position data
|
||||
*/
|
||||
public class LocationPickerViewModel extends AndroidViewModel implements Callback<CameraPosition> {
|
||||
|
||||
/**
|
||||
* Wrapping CameraPosition with MutableLiveData
|
||||
*/
|
||||
private final MutableLiveData<CameraPosition> result = new MutableLiveData<>();
|
||||
|
||||
/**
|
||||
* Constructor for this class
|
||||
*
|
||||
* @param application Application
|
||||
*/
|
||||
public LocationPickerViewModel(@NonNull final Application application) {
|
||||
super(application);
|
||||
}
|
||||
|
||||
/**
|
||||
* Responses on camera position changing
|
||||
*
|
||||
* @param call Call<CameraPosition>
|
||||
* @param response Response<CameraPosition>
|
||||
*/
|
||||
@Override
|
||||
public void onResponse(final @NotNull Call<CameraPosition> call,
|
||||
final Response<CameraPosition> response) {
|
||||
if (response.body() == null) {
|
||||
result.setValue(null);
|
||||
return;
|
||||
}
|
||||
result.setValue(response.body());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(final @NotNull Call<CameraPosition> call, final @NotNull Throwable t) {
|
||||
Timber.e(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets live CameraPosition
|
||||
*
|
||||
* @return MutableLiveData<CameraPosition>
|
||||
*/
|
||||
public MutableLiveData<CameraPosition> getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
30
app/src/main/java/fr/free/nrw/commons/MapController.java
Normal file
30
app/src/main/java/fr/free/nrw/commons/MapController.java
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package fr.free.nrw.commons;
|
||||
|
||||
import fr.free.nrw.commons.location.LatLng;
|
||||
import fr.free.nrw.commons.nearby.Place;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class MapController {
|
||||
|
||||
/**
|
||||
* We pass this variable as a group of placeList and boundaryCoordinates
|
||||
*/
|
||||
public class NearbyPlacesInfo {
|
||||
public List<Place> placeList; // List of nearby places
|
||||
public LatLng[] boundaryCoordinates; // Corners of nearby area
|
||||
public LatLng currentLatLng; // Current location when this places are populated
|
||||
public LatLng searchLatLng; // Search location for finding this places
|
||||
public List<Media> mediaList; // Search location for finding this places
|
||||
}
|
||||
|
||||
/**
|
||||
* We pass this variable as a group of placeList and boundaryCoordinates
|
||||
*/
|
||||
public class ExplorePlacesInfo {
|
||||
public List<Place> explorePlaceList; // List of nearby places
|
||||
public LatLng[] boundaryCoordinates; // Corners of nearby area
|
||||
public LatLng currentLatLng; // Current location when this places are populated
|
||||
public LatLng searchLatLng; // Search location for finding this places
|
||||
public List<Media> mediaList; // Search location for finding this places
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@ package fr.free.nrw.commons
|
|||
|
||||
import android.os.Parcelable
|
||||
import fr.free.nrw.commons.location.LatLng
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import org.wikipedia.dataclient.mwapi.MwQueryPage
|
||||
import org.wikipedia.page.PageTitle
|
||||
import java.util.*
|
||||
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
|
||||
import java.util.UUID
|
||||
|
||||
@Parcelize
|
||||
class Media constructor(
|
||||
|
|
@ -15,7 +17,6 @@ class Media constructor(
|
|||
*/
|
||||
var pageId: String = UUID.randomUUID().toString(),
|
||||
var thumbUrl: String? = null,
|
||||
|
||||
/**
|
||||
* Gets image URL
|
||||
* @return Image URL
|
||||
|
|
@ -27,16 +28,11 @@ class Media constructor(
|
|||
*/
|
||||
var filename: String? = null,
|
||||
/**
|
||||
* Gets the file description.
|
||||
* Gets or sets the file description.
|
||||
* @return file description as a string
|
||||
*/
|
||||
// monolingual description on input...
|
||||
/**
|
||||
* Sets the file description.
|
||||
* @param fallbackDescription the new description of the file
|
||||
*/
|
||||
var fallbackDescription: String? = null,
|
||||
|
||||
/**
|
||||
* Gets the upload date of the file.
|
||||
* Can be null.
|
||||
|
|
@ -44,28 +40,19 @@ class Media constructor(
|
|||
*/
|
||||
var dateUploaded: Date? = null,
|
||||
/**
|
||||
* Gets the license name of the file.
|
||||
* Gets or sets the license name of the file.
|
||||
* @return license as a String
|
||||
*/
|
||||
/**
|
||||
* Sets the license name of the file.
|
||||
*
|
||||
* @param license license name as a String
|
||||
*/
|
||||
var license: String? = null,
|
||||
var licenseUrl: String? = null,
|
||||
/**
|
||||
* Gets the name of the creator of the file.
|
||||
* Gets or sets the name of the creator of the file.
|
||||
* @return author name as a String
|
||||
*/
|
||||
/**
|
||||
* Sets the author name of the file.
|
||||
* @param author creator name as a string
|
||||
*/
|
||||
var author: String? = null,
|
||||
|
||||
var user:String?=null,
|
||||
|
||||
var user: String? = null,
|
||||
/**
|
||||
* Gets the categories the file falls under.
|
||||
* @return file categories as an ArrayList of Strings
|
||||
|
|
@ -84,23 +71,23 @@ class Media constructor(
|
|||
* Stores the mapping of category title to hidden attribute
|
||||
* Example: "Mountains" => false, "CC-BY-SA-2.0" => true
|
||||
*/
|
||||
var categoriesHiddenStatus: Map<String, Boolean> = emptyMap()
|
||||
var categoriesHiddenStatus: Map<String, Boolean> = emptyMap(),
|
||||
) : Parcelable {
|
||||
|
||||
constructor(
|
||||
captions: Map<String, String>,
|
||||
categories: List<String>?,
|
||||
filename: String?,
|
||||
fallbackDescription: String?,
|
||||
author: String?, user:String?
|
||||
author: String?,
|
||||
user: String?,
|
||||
) : this(
|
||||
filename = filename,
|
||||
fallbackDescription = fallbackDescription,
|
||||
dateUploaded = Date(),
|
||||
author = author,
|
||||
user=user,
|
||||
user = user,
|
||||
categories = categories,
|
||||
captions = captions
|
||||
captions = captions,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -109,10 +96,11 @@ class Media constructor(
|
|||
*/
|
||||
val displayTitle: String
|
||||
get() =
|
||||
if (filename != null)
|
||||
if (filename != null) {
|
||||
pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "")
|
||||
else
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets file page title
|
||||
|
|
@ -128,17 +116,21 @@ class Media constructor(
|
|||
get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption)
|
||||
|
||||
val mostRelevantCaption: String
|
||||
get() = captions[Locale.getDefault().language]
|
||||
?: captions.values.firstOrNull()
|
||||
?: displayTitle
|
||||
get() =
|
||||
captions[Locale.getDefault().language]
|
||||
?: captions.values.firstOrNull()
|
||||
?: displayTitle
|
||||
|
||||
/**
|
||||
* Gets the categories the file falls under.
|
||||
* @return file categories as an ArrayList of Strings
|
||||
*/
|
||||
@IgnoredOnParcel
|
||||
var addedCategories: List<String>? = null
|
||||
// TODO added categories should be removed. It is added for a short fix. On category update,
|
||||
// categories should be re-fetched instead
|
||||
get() = field // getter
|
||||
set(value) { field = value } // setter
|
||||
get() = field // getter
|
||||
set(value) {
|
||||
field = value
|
||||
} // setter
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package fr.free.nrw.commons
|
||||
|
||||
import androidx.core.text.HtmlCompat
|
||||
import fr.free.nrw.commons.media.PAGE_ID_PREFIX
|
||||
import fr.free.nrw.commons.media.IdAndCaptions
|
||||
import fr.free.nrw.commons.media.MediaClient
|
||||
import fr.free.nrw.commons.media.PAGE_ID_PREFIX
|
||||
import io.reactivex.Single
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -17,42 +17,46 @@ import javax.inject.Singleton
|
|||
* to the media and may change due to editing.
|
||||
*/
|
||||
@Singleton
|
||||
class MediaDataExtractor @Inject constructor(private val mediaClient: MediaClient) {
|
||||
class MediaDataExtractor
|
||||
@Inject
|
||||
constructor(
|
||||
private val mediaClient: MediaClient,
|
||||
) {
|
||||
fun fetchDepictionIdsAndLabels(media: Media) =
|
||||
mediaClient
|
||||
.getEntities(media.depictionIds)
|
||||
.map {
|
||||
it
|
||||
.entities()
|
||||
.mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
|
||||
}.map { it.map { (key, value) -> IdAndCaptions(key, value) } }
|
||||
.onErrorReturn { emptyList() }
|
||||
|
||||
fun fetchDepictionIdsAndLabels(media: Media) =
|
||||
mediaClient.getEntities(media.depictionIds)
|
||||
.map {
|
||||
it.entities()
|
||||
.mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
|
||||
}
|
||||
.map { it.map { (key, value) -> IdAndCaptions(key, value) } }
|
||||
.onErrorReturn { emptyList() }
|
||||
fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename)
|
||||
|
||||
fun checkDeletionRequestExists(media: Media) =
|
||||
mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename)
|
||||
fun fetchDiscussion(media: Media) =
|
||||
mediaClient
|
||||
.getPageHtml(media.filename!!.replace("File", "File talk"))
|
||||
.map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() }
|
||||
.onErrorReturn {
|
||||
Timber.d("Error occurred while fetching discussion")
|
||||
""
|
||||
}
|
||||
|
||||
fun fetchDiscussion(media: Media) =
|
||||
mediaClient.getPageHtml(media.filename!!.replace("File", "File talk"))
|
||||
.map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() }
|
||||
.onErrorReturn {
|
||||
Timber.d("Error occurred while fetching discussion")
|
||||
""
|
||||
}
|
||||
fun refresh(media: Media): Single<Media> =
|
||||
Single.ambArray(
|
||||
mediaClient
|
||||
.getMediaById(PAGE_ID_PREFIX + media.pageId)
|
||||
.onErrorResumeNext { Single.never() },
|
||||
mediaClient
|
||||
.getMediaSuppressingErrors(media.filename)
|
||||
.onErrorResumeNext { Single.never() },
|
||||
)
|
||||
|
||||
fun refresh(media: Media): Single<Media> {
|
||||
return Single.ambArray(
|
||||
mediaClient.getMediaById(PAGE_ID_PREFIX + media.pageId)
|
||||
.onErrorResumeNext { Single.never() },
|
||||
mediaClient.getMedia(media.filename)
|
||||
.onErrorResumeNext { Single.never() }
|
||||
)
|
||||
fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title)
|
||||
|
||||
/**
|
||||
* Fetches wikitext from mediaClient
|
||||
*/
|
||||
fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title)
|
||||
}
|
||||
|
||||
fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title);
|
||||
|
||||
/**
|
||||
* Fetches wikitext from mediaClient
|
||||
*/
|
||||
fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package fr.free.nrw.commons;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
|
@ -15,28 +15,26 @@ import okhttp3.Response;
|
|||
import okhttp3.ResponseBody;
|
||||
import okhttp3.logging.HttpLoggingInterceptor;
|
||||
import okhttp3.logging.HttpLoggingInterceptor.Level;
|
||||
import org.wikipedia.dataclient.SharedPreferenceCookieManager;
|
||||
import org.wikipedia.dataclient.okhttp.HttpStatusException;
|
||||
import timber.log.Timber;
|
||||
|
||||
public final class OkHttpConnectionFactory {
|
||||
private static final String CACHE_DIR_NAME = "okhttp-cache";
|
||||
private static final long NET_CACHE_SIZE = 64 * 1024 * 1024;
|
||||
@NonNull private static final Cache NET_CACHE = new Cache(new File(CommonsApplication.getInstance().getCacheDir(),
|
||||
CACHE_DIR_NAME), NET_CACHE_SIZE);
|
||||
|
||||
@NonNull
|
||||
private static final OkHttpClient CLIENT = createClient();
|
||||
public static OkHttpClient CLIENT;
|
||||
|
||||
@NonNull public static OkHttpClient getClient() {
|
||||
@NonNull public static OkHttpClient getClient(final CommonsCookieJar cookieJar) {
|
||||
if (CLIENT == null) {
|
||||
CLIENT = createClient(cookieJar);
|
||||
}
|
||||
return CLIENT;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static OkHttpClient createClient() {
|
||||
private static OkHttpClient createClient(final CommonsCookieJar cookieJar) {
|
||||
return new OkHttpClient.Builder()
|
||||
.cookieJar(SharedPreferenceCookieManager.getInstance())
|
||||
.cache(NET_CACHE)
|
||||
.cookieJar(cookieJar)
|
||||
.cache((CommonsApplication.getInstance()!=null) ? new Cache(new File(CommonsApplication.getInstance().getCacheDir(), CACHE_DIR_NAME), NET_CACHE_SIZE) : null)
|
||||
.connectTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
|
|
@ -69,6 +67,8 @@ public final class OkHttpConnectionFactory {
|
|||
}
|
||||
|
||||
public static class UnsuccessfulResponseInterceptor implements Interceptor {
|
||||
private static final String SUPPRESS_ERROR_LOG = "x-commons-suppress-error-log";
|
||||
public static final String SUPPRESS_ERROR_LOG_HEADER = SUPPRESS_ERROR_LOG+": true";
|
||||
private static final List<String> DO_NOT_INTERCEPT = Collections.singletonList(
|
||||
"api.php?format=json&formatversion=2&errorformat=plaintext&action=upload&ignorewarnings=1");
|
||||
|
||||
|
|
@ -77,7 +77,16 @@ public final class OkHttpConnectionFactory {
|
|||
@Override
|
||||
@NonNull
|
||||
public Response intercept(@NonNull final Chain chain) throws IOException {
|
||||
final Response rsp = chain.proceed(chain.request());
|
||||
final Request rq = chain.request();
|
||||
|
||||
// If the request contains our special "suppress errors" header, make note of it
|
||||
// but don't pass that on to the server.
|
||||
final boolean suppressErrors = rq.headers().names().contains(SUPPRESS_ERROR_LOG);
|
||||
final Request request = rq.newBuilder()
|
||||
.removeHeader(SUPPRESS_ERROR_LOG)
|
||||
.build();
|
||||
|
||||
final Response rsp = chain.proceed(request);
|
||||
|
||||
// Do not intercept certain requests and let the caller handle the errors
|
||||
if(isExcludedUrl(chain.request())) {
|
||||
|
|
@ -91,7 +100,12 @@ public final class OkHttpConnectionFactory {
|
|||
}
|
||||
}
|
||||
} catch (final IOException e) {
|
||||
Timber.e(e);
|
||||
// Log the error as debug (and therefore, "expected") or at error level
|
||||
if (suppressErrors) {
|
||||
Timber.d(e, "Suppressed (known / expected) error");
|
||||
} else {
|
||||
Timber.e(e);
|
||||
}
|
||||
}
|
||||
return rsp;
|
||||
}
|
||||
|
|
@ -111,4 +125,30 @@ public final class OkHttpConnectionFactory {
|
|||
|
||||
private OkHttpConnectionFactory() {
|
||||
}
|
||||
|
||||
public static class HttpStatusException extends IOException {
|
||||
private final int code;
|
||||
private final String url;
|
||||
public HttpStatusException(@NonNull Response rsp) {
|
||||
this.code = rsp.code();
|
||||
this.url = rsp.request().url().uri().toString();
|
||||
try {
|
||||
if (rsp.body() != null && rsp.body().contentType() != null
|
||||
&& rsp.body().contentType().toString().contains("json")) {
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Log?
|
||||
}
|
||||
}
|
||||
|
||||
public int code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage() {
|
||||
String str = "Code: " + code + ", URL: " + url;
|
||||
return str;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@ package fr.free.nrw.commons
|
|||
internal object Urls {
|
||||
const val NEW_ISSUE_URL = "https://github.com/commons-app/apps-android-commons/issues"
|
||||
const val GITHUB_REPO_URL = "https://github.com/commons-app/apps-android-commons"
|
||||
const val GITHUB_PACKAGE_NAME = "com.github.android"
|
||||
const val WEBSITE_URL = "https://commons-app.github.io"
|
||||
const val CREDITS_URL = "https://github.com/commons-app/apps-android-commons/blob/master/CREDITS"
|
||||
const val USER_GUIDE_URL = "https://commons-app.github.io/docs.html"
|
||||
const val FAQ_URL = "https://github.com/commons-app/commons-app-documentation/blob/master/android/Frequently-Asked-Questions.md"
|
||||
const val PLAY_STORE_PREFIX = "market://details?id="
|
||||
const val PLAY_STORE_URL_PREFIX = "https://play.google.com/store/apps/details?id="
|
||||
const val TRANSLATE_WIKI_URL = "https://translatewiki.net/w/i.php?title=Special:Translate&group=commons-android-strings&filter=%21translated&action=translate&language="
|
||||
const val TRANSLATE_WIKI_URL =
|
||||
"https://translatewiki.net/w/i.php?title=Special:Translate" +
|
||||
"&group=commons-android-strings&filter=%21translated&action=translate&language="
|
||||
const val FACEBOOK_WEB_URL = "https://www.facebook.com/1921335171459985"
|
||||
const val FACEBOOK_APP_URL = "fb://page/1921335171459985"
|
||||
const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana"
|
||||
|
|
|
|||
|
|
@ -10,17 +10,16 @@ import android.text.SpannableString;
|
|||
import android.text.style.UnderlineSpan;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams;
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import org.wikipedia.dataclient.WikiSite;
|
||||
import org.wikipedia.page.PageTitle;
|
||||
import fr.free.nrw.commons.wikidata.model.WikiSite;
|
||||
import fr.free.nrw.commons.wikidata.model.page.PageTitle;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
|
@ -30,9 +29,6 @@ import fr.free.nrw.commons.settings.Prefs;
|
|||
import fr.free.nrw.commons.utils.ViewUtil;
|
||||
import timber.log.Timber;
|
||||
|
||||
import static android.widget.Toast.LENGTH_SHORT;
|
||||
import static fr.free.nrw.commons.campaigns.CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE;
|
||||
|
||||
public class Utils {
|
||||
|
||||
public static PageTitle getPageTitle(@NonNull String title) {
|
||||
|
|
@ -136,12 +132,6 @@ public class Utils {
|
|||
*/
|
||||
public static void handleWebUrl(Context context, Uri url) {
|
||||
Timber.d("Launching web url %s", url.toString());
|
||||
Intent browserIntent = new Intent(Intent.ACTION_VIEW, url);
|
||||
if (browserIntent.resolveActivity(context.getPackageManager()) == null) {
|
||||
Toast toast = Toast.makeText(context, context.getString(R.string.no_web_browser), LENGTH_SHORT);
|
||||
toast.show();
|
||||
return;
|
||||
}
|
||||
|
||||
final CustomTabColorSchemeParams color = new CustomTabColorSchemeParams.Builder()
|
||||
.setToolbarColor(ContextCompat.getColor(context, R.color.primaryColor))
|
||||
|
|
@ -243,4 +233,18 @@ public class Utils {
|
|||
return "30 Sep";
|
||||
}
|
||||
|
||||
/***
|
||||
* Function to get the current WLM year
|
||||
* It increments at the start of September in line with the other WLM functions
|
||||
* (No consideration of locales for now)
|
||||
* @param calendar
|
||||
* @return
|
||||
*/
|
||||
public static int getWikiLovesMonumentsYear(Calendar calendar) {
|
||||
int year = calendar.get(Calendar.YEAR);
|
||||
if (calendar.get(Calendar.MONTH) < Calendar.SEPTEMBER) {
|
||||
year -= 1;
|
||||
}
|
||||
return year;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,25 @@
|
|||
package fr.free.nrw.commons;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
import com.viewpagerindicator.CirclePageIndicator;
|
||||
|
||||
import butterknife.BindView;
|
||||
import butterknife.ButterKnife;
|
||||
import butterknife.OnClick;
|
||||
import fr.free.nrw.commons.databinding.ActivityWelcomeBinding;
|
||||
import fr.free.nrw.commons.databinding.PopupForCopyrightBinding;
|
||||
import fr.free.nrw.commons.quiz.QuizActivity;
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import fr.free.nrw.commons.utils.ConfigUtils;
|
||||
import android.app.AlertDialog;
|
||||
import android.widget.Button;
|
||||
|
||||
public class WelcomeActivity extends BaseActivity {
|
||||
|
||||
@BindView(R.id.welcomePager)
|
||||
ViewPager pager;
|
||||
@BindView(R.id.welcomePagerIndicator)
|
||||
CirclePageIndicator indicator;
|
||||
private ActivityWelcomeBinding binding;
|
||||
private PopupForCopyrightBinding copyrightBinding;
|
||||
|
||||
private WelcomePagerAdapter adapter = new WelcomePagerAdapter();
|
||||
private final WelcomePagerAdapter adapter = new WelcomePagerAdapter();
|
||||
private boolean isQuiz;
|
||||
private AlertDialog.Builder dialogBuilder;
|
||||
private AlertDialog dialog;
|
||||
Button okButton;
|
||||
|
||||
/**
|
||||
* Initialises exiting fields and dependencies
|
||||
|
|
@ -37,12 +27,14 @@ public class WelcomeActivity extends BaseActivity {
|
|||
* @param savedInstanceState WelcomeActivity bundled data
|
||||
*/
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
public void onCreate(final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_welcome);
|
||||
binding = ActivityWelcomeBinding.inflate(getLayoutInflater());
|
||||
final View view = binding.getRoot();
|
||||
setContentView(view);
|
||||
|
||||
if (getIntent() != null) {
|
||||
Bundle bundle = getIntent().getExtras();
|
||||
final Bundle bundle = getIntent().getExtras();
|
||||
if (bundle != null) {
|
||||
isQuiz = bundle.getBoolean("isQuiz");
|
||||
}
|
||||
|
|
@ -52,22 +44,24 @@ public class WelcomeActivity extends BaseActivity {
|
|||
|
||||
// Enable skip button if beta flavor
|
||||
if (ConfigUtils.isBetaFlavour()) {
|
||||
findViewById(R.id.finishTutorialButton).setVisibility(View.VISIBLE);
|
||||
binding.finishTutorialButton.setVisibility(View.VISIBLE);
|
||||
|
||||
dialogBuilder = new AlertDialog.Builder(this);
|
||||
final View contactPopupView = getLayoutInflater().inflate(R.layout.popup_for_copyright,null);
|
||||
copyrightBinding = PopupForCopyrightBinding.inflate(getLayoutInflater());
|
||||
final View contactPopupView = copyrightBinding.getRoot();
|
||||
dialogBuilder.setView(contactPopupView);
|
||||
dialogBuilder.setCancelable(false);
|
||||
dialog = dialogBuilder.create();
|
||||
dialog.show();
|
||||
|
||||
okButton = dialog.findViewById(R.id.button_ok);
|
||||
okButton.setOnClickListener(view -> dialog.dismiss());
|
||||
copyrightBinding.buttonOk.setOnClickListener(v -> dialog.dismiss());
|
||||
}
|
||||
|
||||
ButterKnife.bind(this);
|
||||
binding.welcomePager.setAdapter(adapter);
|
||||
binding.welcomePagerIndicator.setViewPager(binding.welcomePager);
|
||||
|
||||
binding.finishTutorialButton.setOnClickListener(v -> finishTutorial());
|
||||
|
||||
pager.setAdapter(adapter);
|
||||
indicator.setViewPager(pager);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -76,7 +70,7 @@ public class WelcomeActivity extends BaseActivity {
|
|||
@Override
|
||||
public void onDestroy() {
|
||||
if (isQuiz) {
|
||||
Intent i = new Intent(WelcomeActivity.this, QuizActivity.class);
|
||||
final Intent i = new Intent(this, QuizActivity.class);
|
||||
startActivity(i);
|
||||
}
|
||||
super.onDestroy();
|
||||
|
|
@ -87,8 +81,8 @@ public class WelcomeActivity extends BaseActivity {
|
|||
*
|
||||
* @param context Activity context
|
||||
*/
|
||||
public static void startYourself(Context context) {
|
||||
Intent welcomeIntent = new Intent(context, WelcomeActivity.class);
|
||||
public static void startYourself(final Context context) {
|
||||
final Intent welcomeIntent = new Intent(context, WelcomeActivity.class);
|
||||
context.startActivity(welcomeIntent);
|
||||
}
|
||||
|
||||
|
|
@ -97,8 +91,8 @@ public class WelcomeActivity extends BaseActivity {
|
|||
*/
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (pager.getCurrentItem() != 0) {
|
||||
pager.setCurrentItem(pager.getCurrentItem() - 1, true);
|
||||
if (binding.welcomePager.getCurrentItem() != 0) {
|
||||
binding.welcomePager.setCurrentItem(binding.welcomePager.getCurrentItem() - 1, true);
|
||||
} else {
|
||||
if (defaultKvStore.getBoolean("firstrun", true)) {
|
||||
finishAffinity();
|
||||
|
|
@ -108,7 +102,6 @@ public class WelcomeActivity extends BaseActivity {
|
|||
}
|
||||
}
|
||||
|
||||
@OnClick(R.id.finishTutorialButton)
|
||||
public void finishTutorial() {
|
||||
defaultKvStore.putBoolean("firstrun", false);
|
||||
finish();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package fr.free.nrw.commons;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.text.Html;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package fr.free.nrw.commons.actions
|
||||
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwResponse
|
||||
|
||||
/**
|
||||
* Response of the Thanks API.
|
||||
* Context:
|
||||
* The Commons Android app lets you thank other contributors who have uploaded a great picture.
|
||||
* See https://www.mediawiki.org/wiki/Extension:Thanks
|
||||
*/
|
||||
class MwThankPostResponse : MwResponse() {
|
||||
var result: Result? = null
|
||||
|
||||
inner class Result {
|
||||
var success: Int? = null
|
||||
var recipient: String? = null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
package fr.free.nrw.commons.actions
|
||||
|
||||
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
|
||||
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
import org.wikipedia.csrf.CsrfTokenClient
|
||||
|
||||
/**
|
||||
* This class acts as a Client to facilitate wiki page editing
|
||||
|
|
@ -13,9 +14,8 @@ import org.wikipedia.csrf.CsrfTokenClient
|
|||
*/
|
||||
class PageEditClient(
|
||||
private val csrfTokenClient: CsrfTokenClient,
|
||||
private val pageEditInterface: PageEditInterface
|
||||
private val pageEditInterface: PageEditInterface,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Replace the content of a wiki page
|
||||
* @param pageTitle Title of the page to edit
|
||||
|
|
@ -23,14 +23,60 @@ class PageEditClient(
|
|||
* @param summary Edit summary
|
||||
* @return whether the edit was successful
|
||||
*/
|
||||
fun edit(pageTitle: String, text: String, summary: String): Observable<Boolean> {
|
||||
return try {
|
||||
pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.tokenBlocking)
|
||||
.map { editResponse -> editResponse.edit()!!.editSucceeded() }
|
||||
fun edit(
|
||||
pageTitle: String,
|
||||
text: String,
|
||||
summary: String,
|
||||
): Observable<Boolean> =
|
||||
try {
|
||||
pageEditInterface
|
||||
.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking())
|
||||
.map { editResponse ->
|
||||
editResponse.edit()!!.editSucceeded()
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
Observable.just(false)
|
||||
if (throwable is InvalidLoginTokenException) {
|
||||
throw throwable
|
||||
} else {
|
||||
Observable.just(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new page with the given title, text, and summary.
|
||||
*
|
||||
* @param pageTitle The title of the page to be created.
|
||||
* @param text The content of the page in wikitext format.
|
||||
* @param summary The edit summary for the page creation.
|
||||
* @return An observable that emits true if the page creation succeeded, false otherwise.
|
||||
* @throws InvalidLoginTokenException If an invalid login token is encountered during the process.
|
||||
*/
|
||||
fun postCreate(
|
||||
pageTitle: String,
|
||||
text: String,
|
||||
summary: String,
|
||||
): Observable<Boolean> =
|
||||
try {
|
||||
pageEditInterface
|
||||
.postCreate(
|
||||
pageTitle,
|
||||
summary,
|
||||
text,
|
||||
"text/x-wiki",
|
||||
"wikitext",
|
||||
true,
|
||||
true,
|
||||
csrfTokenClient.getTokenBlocking(),
|
||||
).map { editResponse ->
|
||||
editResponse.edit()!!.editSucceeded()
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
if (throwable is InvalidLoginTokenException) {
|
||||
throw throwable
|
||||
} else {
|
||||
Observable.just(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text to the end of a wiki page
|
||||
|
|
@ -39,14 +85,22 @@ class PageEditClient(
|
|||
* @param summary Edit summary
|
||||
* @return whether the edit was successful
|
||||
*/
|
||||
fun appendEdit(pageTitle: String, appendText: String, summary: String): Observable<Boolean> {
|
||||
return try {
|
||||
pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.tokenBlocking)
|
||||
fun appendEdit(
|
||||
pageTitle: String,
|
||||
appendText: String,
|
||||
summary: String,
|
||||
): Observable<Boolean> =
|
||||
try {
|
||||
pageEditInterface
|
||||
.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking())
|
||||
.map { editResponse -> editResponse.edit()!!.editSucceeded() }
|
||||
} catch (throwable: Throwable) {
|
||||
Observable.just(false)
|
||||
if (throwable is InvalidLoginTokenException) {
|
||||
throw throwable
|
||||
} else {
|
||||
Observable.just(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend text to the beginning of a wiki page
|
||||
|
|
@ -55,14 +109,48 @@ class PageEditClient(
|
|||
* @param summary Edit summary
|
||||
* @return whether the edit was successful
|
||||
*/
|
||||
fun prependEdit(pageTitle: String, prependText: String, summary: String): Observable<Boolean> {
|
||||
return try {
|
||||
pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.tokenBlocking)
|
||||
fun prependEdit(
|
||||
pageTitle: String,
|
||||
prependText: String,
|
||||
summary: String,
|
||||
): Observable<Boolean> =
|
||||
try {
|
||||
pageEditInterface
|
||||
.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking())
|
||||
.map { editResponse -> editResponse.edit()?.editSucceeded() ?: false }
|
||||
} catch (throwable: Throwable) {
|
||||
if (throwable is InvalidLoginTokenException) {
|
||||
throw throwable
|
||||
} else {
|
||||
Observable.just(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new section to the wiki page
|
||||
* @param pageTitle Title of the page to edit
|
||||
* @param sectionTitle Title of the new section that needs to be created
|
||||
* @param sectionText The page content that is to be added to the section
|
||||
* @param summary Edit summary
|
||||
* @return whether the edit was successful
|
||||
*/
|
||||
fun createNewSection(
|
||||
pageTitle: String,
|
||||
sectionTitle: String,
|
||||
sectionText: String,
|
||||
summary: String,
|
||||
): Observable<Boolean> =
|
||||
try {
|
||||
pageEditInterface
|
||||
.postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking())
|
||||
.map { editResponse -> editResponse.edit()!!.editSucceeded() }
|
||||
} catch (throwable: Throwable) {
|
||||
Observable.just(false)
|
||||
if (throwable is InvalidLoginTokenException) {
|
||||
throw throwable
|
||||
} else {
|
||||
Observable.just(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set new labels to Wikibase server of commons
|
||||
|
|
@ -72,24 +160,42 @@ class PageEditClient(
|
|||
* @param value label
|
||||
* @return 1 when the edit was successful
|
||||
*/
|
||||
fun setCaptions(summary: String, title: String,
|
||||
language: String, value: String) : Observable<Int>{
|
||||
return try {
|
||||
pageEditInterface.postCaptions(summary, title, language,
|
||||
value, csrfTokenClient.tokenBlocking).map { it.success }
|
||||
fun setCaptions(
|
||||
summary: String,
|
||||
title: String,
|
||||
language: String,
|
||||
value: String,
|
||||
): Observable<Int> =
|
||||
try {
|
||||
pageEditInterface
|
||||
.postCaptions(
|
||||
summary,
|
||||
title,
|
||||
language,
|
||||
value,
|
||||
csrfTokenClient.getTokenBlocking(),
|
||||
).map { it.success }
|
||||
} catch (throwable: Throwable) {
|
||||
Observable.just(0)
|
||||
if (throwable is InvalidLoginTokenException) {
|
||||
throw throwable
|
||||
} else {
|
||||
Observable.just(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whole WikiText of required file
|
||||
* @param title : Name of the file
|
||||
* @return Observable<MwQueryResult>
|
||||
*/
|
||||
fun getCurrentWikiText(title: String): Single<String?> {
|
||||
return pageEditInterface.getWikiText(title).map {
|
||||
it.query()?.pages()?.get(0)?.revisions()?.get(0)?.content()
|
||||
fun getCurrentWikiText(title: String): Single<String?> =
|
||||
pageEditInterface.getWikiText(title).map {
|
||||
it
|
||||
.query()
|
||||
?.pages()
|
||||
?.get(0)
|
||||
?.revisions()
|
||||
?.get(0)
|
||||
?.content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
package fr.free.nrw.commons.actions
|
||||
|
||||
import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
|
||||
import fr.free.nrw.commons.wikidata.model.Entities
|
||||
import fr.free.nrw.commons.wikidata.model.edit.Edit
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
import org.wikipedia.dataclient.Service
|
||||
import org.wikipedia.dataclient.mwapi.MwQueryResponse
|
||||
import org.wikipedia.edit.Edit
|
||||
import org.wikipedia.wikidata.Entities
|
||||
import retrofit2.http.*
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* This interface facilitates wiki commons page editing services to the Networking module
|
||||
|
|
@ -27,13 +32,40 @@ interface PageEditInterface {
|
|||
*/
|
||||
@FormUrlEncoded
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@POST(Service.MW_API_PREFIX + "action=edit")
|
||||
@POST(MW_API_PREFIX + "action=edit")
|
||||
fun postEdit(
|
||||
@Field("title") title: String,
|
||||
@Field("summary") summary: String,
|
||||
@Field("text") text: String,
|
||||
// NOTE: This csrf shold always be sent as the last field of form data
|
||||
@Field("token") token: String
|
||||
@Field("token") token: String,
|
||||
): Observable<Edit>
|
||||
|
||||
/**
|
||||
* This method creates or edits a page for nearby items.
|
||||
*
|
||||
* @param title Title of the page to edit. Cannot be used together with pageid.
|
||||
* @param summary Edit summary. Also used as the section title when section=new and sectiontitle is not set.
|
||||
* @param text Text of the page.
|
||||
* @param contentformat Format of the content (e.g., "text/x-wiki").
|
||||
* @param contentmodel Model of the content (e.g., "wikitext").
|
||||
* @param minor Whether the edit is a minor edit.
|
||||
* @param recreate Whether to recreate the page if it does not exist.
|
||||
* @param token A "csrf" token. This should always be sent as the last field of form data.
|
||||
*/
|
||||
@FormUrlEncoded
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@POST(MW_API_PREFIX + "action=edit")
|
||||
fun postCreate(
|
||||
@Field("title") title: String,
|
||||
@Field("summary") summary: String,
|
||||
@Field("text") text: String,
|
||||
@Field("contentformat") contentformat: String,
|
||||
@Field("contentmodel") contentmodel: String,
|
||||
@Field("minor") minor: Boolean,
|
||||
@Field("recreate") recreate: Boolean,
|
||||
// NOTE: This csrf shold always be sent as the last field of form data
|
||||
@Field("token") token: String,
|
||||
): Observable<Edit>
|
||||
|
||||
/**
|
||||
|
|
@ -47,12 +79,12 @@ interface PageEditInterface {
|
|||
*/
|
||||
@FormUrlEncoded
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@POST(Service.MW_API_PREFIX + "action=edit")
|
||||
@POST(MW_API_PREFIX + "action=edit")
|
||||
fun postAppendEdit(
|
||||
@Field("title") title: String,
|
||||
@Field("summary") summary: String,
|
||||
@Field("appendtext") appendText: String,
|
||||
@Field("token") token: String
|
||||
@Field("token") token: String,
|
||||
): Observable<Edit>
|
||||
|
||||
/**
|
||||
|
|
@ -66,24 +98,34 @@ interface PageEditInterface {
|
|||
*/
|
||||
@FormUrlEncoded
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@POST(Service.MW_API_PREFIX + "action=edit")
|
||||
@POST(MW_API_PREFIX + "action=edit")
|
||||
fun postPrependEdit(
|
||||
@Field("title") title: String,
|
||||
@Field("summary") summary: String,
|
||||
@Field("prependtext") prependText: String,
|
||||
@Field("token") token: String
|
||||
@Field("token") token: String,
|
||||
): Observable<Edit>
|
||||
|
||||
|
||||
@FormUrlEncoded
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@POST(Service.MW_API_PREFIX + "action=wbsetlabel&format=json&site=commonswiki&formatversion=2")
|
||||
@POST(MW_API_PREFIX + "action=edit§ion=new")
|
||||
fun postNewSection(
|
||||
@Field("title") title: String,
|
||||
@Field("summary") summary: String,
|
||||
@Field("sectiontitle") sectionTitle: String,
|
||||
@Field("text") sectionText: String,
|
||||
@Field("token") token: String,
|
||||
): Observable<Edit>
|
||||
|
||||
@FormUrlEncoded
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@POST(MW_API_PREFIX + "action=wbsetlabel&format=json&site=commonswiki&formatversion=2")
|
||||
fun postCaptions(
|
||||
@Field("summary") summary: String,
|
||||
@Field("title") title: String,
|
||||
@Field("language") language: String,
|
||||
@Field("value") value: String,
|
||||
@Field("token") token: String
|
||||
@Field("token") token: String,
|
||||
): Observable<Entities>
|
||||
|
||||
/**
|
||||
|
|
@ -91,11 +133,8 @@ interface PageEditInterface {
|
|||
* @param titles : Name of the file
|
||||
* @return Single<MwQueryResult>
|
||||
*/
|
||||
@GET(
|
||||
Service.MW_API_PREFIX +
|
||||
"action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles="
|
||||
)
|
||||
@GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=")
|
||||
fun getWikiText(
|
||||
@Query("titles") title: String
|
||||
@Query("titles") title: String,
|
||||
): Single<MwQueryResponse?>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package fr.free.nrw.commons.actions
|
||||
|
||||
import fr.free.nrw.commons.CommonsApplication
|
||||
import fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF
|
||||
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
|
||||
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
|
||||
import fr.free.nrw.commons.di.NetworkingModule.Companion.NAMED_COMMONS_CSRF
|
||||
import io.reactivex.Observable
|
||||
import org.wikipedia.csrf.CsrfTokenClient
|
||||
import org.wikipedia.dataclient.Service
|
||||
import org.wikipedia.dataclient.mwapi.MwPostResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -15,22 +14,33 @@ import javax.inject.Singleton
|
|||
* Thanks are used by a user to show gratitude to another user for their contributions
|
||||
*/
|
||||
@Singleton
|
||||
class ThanksClient @Inject constructor(
|
||||
@param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient,
|
||||
@param:Named("commons-service") private val service: Service
|
||||
) {
|
||||
/**
|
||||
* Thanks a user for a particular revision
|
||||
* @param revisionId The revision ID the user would like to thank someone for
|
||||
* @return if thanks was successfully sent to intended recipient
|
||||
*/
|
||||
fun thank(revisionId: Long): Observable<Boolean> {
|
||||
return try {
|
||||
service.thank(revisionId.toString(), null, csrfTokenClient.tokenBlocking, CommonsApplication.getInstance().userAgent)
|
||||
.map { mwThankPostResponse -> mwThankPostResponse.result.success== 1 }
|
||||
} catch (throwable: Throwable) {
|
||||
Observable.just(false)
|
||||
}
|
||||
class ThanksClient
|
||||
@Inject
|
||||
constructor(
|
||||
@param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient,
|
||||
private val service: ThanksInterface,
|
||||
) {
|
||||
/**
|
||||
* Thanks a user for a particular revision
|
||||
* @param revisionId The revision ID the user would like to thank someone for
|
||||
* @return if thanks was successfully sent to intended recipient
|
||||
*/
|
||||
fun thank(revisionId: Long): Observable<Boolean> =
|
||||
try {
|
||||
service
|
||||
.thank(
|
||||
revisionId.toString(), // Rev
|
||||
null, // Log
|
||||
csrfTokenClient.getTokenBlocking(), // Token
|
||||
CommonsApplication.instance.userAgent, // Source
|
||||
).map { mwThankPostResponse ->
|
||||
mwThankPostResponse.result?.success == 1
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
if (throwable is InvalidLoginTokenException) {
|
||||
Observable.error(throwable)
|
||||
} else {
|
||||
Observable.just(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package fr.free.nrw.commons.actions
|
||||
|
||||
import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
|
||||
import io.reactivex.Observable
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Thanks API.
|
||||
* Context:
|
||||
* The Commons Android app lets you thank another contributor who has uploaded a great picture.
|
||||
* See https://www.mediawiki.org/wiki/Extension:Thanks
|
||||
*/
|
||||
interface ThanksInterface {
|
||||
@FormUrlEncoded
|
||||
@POST(MW_API_PREFIX + "action=thank")
|
||||
fun thank(
|
||||
@Field("rev") rev: String?,
|
||||
@Field("log") log: String?,
|
||||
@Field("token") token: String,
|
||||
@Field("source") source: String?,
|
||||
): Observable<MwThankPostResponse?>
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
package fr.free.nrw.commons.activity
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import fr.free.nrw.commons.R
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* SingleWebViewActivity is a reusable activity webView based on a given url(initial url) and
|
||||
* closes itself when a specified success URL is reached to success url.
|
||||
*/
|
||||
class SingleWebViewActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val url = intent.getStringExtra(VANISH_ACCOUNT_URL)
|
||||
val successUrl = intent.getStringExtra(VANISH_ACCOUNT_SUCCESS_URL)
|
||||
if (url == null || successUrl == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
modifier = Modifier,
|
||||
title = { Text(getString(R.string.vanish_account)) },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Close the WebView Activity if the user taps the back button
|
||||
finish()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
// TODO("Add contentDescription)
|
||||
contentDescription = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
content = {
|
||||
WebViewComponent(
|
||||
url = url,
|
||||
successUrl = successUrl,
|
||||
onSuccess = {
|
||||
// TODO Redirect the user to login screen like we do when the user logout's
|
||||
finish()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(it)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param url The initial URL which we are loading in the WebView.
|
||||
* @param successUrl The URL that, when reached, triggers the `onSuccess` callback.
|
||||
* @param onSuccess A callback that is invoked when the current url of webView is successUrl.
|
||||
* This is used when we want to close when the webView once a success url is hit.
|
||||
* @param modifier An optional [Modifier] to customize the layout or appearance of the WebView.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun WebViewComponent(
|
||||
url: String,
|
||||
successUrl: String,
|
||||
onSuccess: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
WebView(it).apply {
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
|
||||
}
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): Boolean {
|
||||
|
||||
request?.url?.let { url ->
|
||||
Timber.d("URL Loading: $url")
|
||||
if (url.toString() == successUrl) {
|
||||
Timber.d("Success URL detected. Closing WebView.")
|
||||
onSuccess() // Close the activity
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(message: ConsoleMessage): Boolean {
|
||||
Timber.d("Console: ${message.message()} -- From line ${message.lineNumber()} of ${message.sourceId()}")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
update = {
|
||||
webView.value = it
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VANISH_ACCOUNT_URL = "VanishAccountUrl"
|
||||
private const val VANISH_ACCOUNT_SUCCESS_URL = "vanishAccountSuccessUrl"
|
||||
|
||||
/**
|
||||
* Launch the WebViewActivity with the specified URL and success URL.
|
||||
* @param context The context from which the activity is launched.
|
||||
* @param url The initial URL to load in the WebView.
|
||||
* @param successUrl The URL that triggers the WebView to close when matched.
|
||||
*/
|
||||
fun showWebView(
|
||||
context: Context,
|
||||
url: String,
|
||||
successUrl: String
|
||||
) {
|
||||
val intent = Intent(
|
||||
context,
|
||||
SingleWebViewActivity::class.java
|
||||
).apply {
|
||||
putExtra(VANISH_ACCOUNT_URL, url)
|
||||
putExtra(VANISH_ACCOUNT_SUCCESS_URL, successUrl)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
Normal file
24
app/src/main/java/fr/free/nrw/commons/auth/AccountUtil.kt
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package fr.free.nrw.commons.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
|
||||
import timber.log.Timber
|
||||
|
||||
const val AUTH_TOKEN_TYPE: String = "CommonsAndroid"
|
||||
|
||||
fun getUserName(context: Context): String? {
|
||||
return account(context)?.name
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun account(context: Context): Account? = try {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
if (accounts.isNotEmpty()) accounts[0] else null
|
||||
} catch (e: SecurityException) {
|
||||
Timber.e(e)
|
||||
null
|
||||
}
|
||||
|
|
@ -1,496 +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.Button;
|
||||
import android.widget.EditText;
|
||||
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 com.google.android.material.textfield.TextInputLayout;
|
||||
|
||||
import fr.free.nrw.commons.utils.ActivityUtils;
|
||||
import java.util.Locale;
|
||||
import org.wikipedia.AppAdapter;
|
||||
import org.wikipedia.dataclient.ServiceFactory;
|
||||
import org.wikipedia.dataclient.WikiSite;
|
||||
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
|
||||
import org.wikipedia.login.LoginClient;
|
||||
import org.wikipedia.login.LoginClient.LoginCallback;
|
||||
import org.wikipedia.login.LoginResult;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
|
||||
import butterknife.BindView;
|
||||
import butterknife.ButterKnife;
|
||||
import butterknife.OnClick;
|
||||
import butterknife.OnEditorAction;
|
||||
import butterknife.OnFocusChange;
|
||||
import fr.free.nrw.commons.BuildConfig;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.Utils;
|
||||
import fr.free.nrw.commons.WelcomeActivity;
|
||||
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 retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
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.di.NetworkingModule.NAMED_COMMONS_WIKI_SITE;
|
||||
|
||||
public class LoginActivity extends AccountAuthenticatorActivity {
|
||||
|
||||
@Inject
|
||||
SessionManager sessionManager;
|
||||
|
||||
@Inject
|
||||
@Named(NAMED_COMMONS_WIKI_SITE)
|
||||
WikiSite commonsWikiSite;
|
||||
|
||||
@Inject
|
||||
@Named("default_preferences")
|
||||
JsonKvStore applicationKvStore;
|
||||
|
||||
@Inject
|
||||
LoginClient loginClient;
|
||||
|
||||
@Inject
|
||||
SystemThemeUtils systemThemeUtils;
|
||||
|
||||
@BindView(R.id.login_button)
|
||||
Button loginButton;
|
||||
|
||||
@BindView(R.id.login_username)
|
||||
EditText usernameEdit;
|
||||
|
||||
@BindView(R.id.login_password)
|
||||
EditText passwordEdit;
|
||||
|
||||
@BindView(R.id.login_two_factor)
|
||||
EditText twoFactorEdit;
|
||||
|
||||
@BindView(R.id.error_message_container)
|
||||
ViewGroup errorMessageContainer;
|
||||
|
||||
@BindView(R.id.error_message)
|
||||
TextView errorMessage;
|
||||
|
||||
@BindView(R.id.login_credentials)
|
||||
TextView loginCredentials;
|
||||
|
||||
@BindView(R.id.two_factor_container)
|
||||
TextInputLayout twoFactorContainer;
|
||||
|
||||
ProgressDialog progressDialog;
|
||||
private AppCompatDelegate delegate;
|
||||
private LoginTextWatcher textWatcher = new LoginTextWatcher();
|
||||
private CompositeDisposable compositeDisposable = new CompositeDisposable();
|
||||
private Call<MwQueryResponse> loginToken;
|
||||
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);
|
||||
|
||||
setContentView(R.layout.activity_login);
|
||||
|
||||
ButterKnife.bind(this);
|
||||
|
||||
usernameEdit.addTextChangedListener(textWatcher);
|
||||
passwordEdit.addTextChangedListener(textWatcher);
|
||||
twoFactorEdit.addTextChangedListener(textWatcher);
|
||||
|
||||
if (ConfigUtils.isBetaFlavour()) {
|
||||
loginCredentials.setText(getString(R.string.login_credential));
|
||||
} else {
|
||||
loginCredentials.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@OnFocusChange(R.id.login_password)
|
||||
void onPasswordFocusChanged(View view, boolean hasFocus) {
|
||||
if (!hasFocus) {
|
||||
ViewUtil.hideKeyboard(view);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEditorAction(R.id.login_password)
|
||||
boolean onEditorAction(int actionId, KeyEvent keyEvent) {
|
||||
if (loginButton.isEnabled()) {
|
||||
if (actionId == IME_ACTION_DONE) {
|
||||
performLogin();
|
||||
return true;
|
||||
} else if ((keyEvent != null) && keyEvent.getKeyCode() == KEYCODE_ENTER) {
|
||||
performLogin();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@OnClick(R.id.skip_login)
|
||||
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();
|
||||
}
|
||||
|
||||
@OnClick(R.id.forgot_password)
|
||||
void forgotPassword() {
|
||||
Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL));
|
||||
}
|
||||
|
||||
@OnClick(R.id.about_privacy_policy)
|
||||
void onPrivacyPolicyClicked() {
|
||||
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
|
||||
}
|
||||
|
||||
@OnClick(R.id.sign_up_button)
|
||||
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();
|
||||
}
|
||||
usernameEdit.removeTextChangedListener(textWatcher);
|
||||
passwordEdit.removeTextChangedListener(textWatcher);
|
||||
twoFactorEdit.removeTextChangedListener(textWatcher);
|
||||
delegate.onDestroy();
|
||||
if(null!=loginClient) {
|
||||
loginClient.cancel();
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@OnClick(R.id.login_button)
|
||||
public void performLogin() {
|
||||
Timber.d("Login to start!");
|
||||
final String username = usernameEdit.getText().toString();
|
||||
final String rawUsername = usernameEdit.getText().toString().trim();
|
||||
final String password = passwordEdit.getText().toString();
|
||||
String twoFactorCode = twoFactorEdit.getText().toString();
|
||||
|
||||
showLoggingProgressBar();
|
||||
doLogin(username, password, twoFactorCode);
|
||||
}
|
||||
|
||||
private void doLogin(String username, String password, String twoFactorCode) {
|
||||
progressDialog.show();
|
||||
loginToken = ServiceFactory.get(commonsWikiSite).getLoginToken();
|
||||
loginToken.enqueue(
|
||||
new Callback<MwQueryResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<MwQueryResponse> call,
|
||||
Response<MwQueryResponse> response) {
|
||||
loginClient.login(commonsWikiSite, username, password, null, twoFactorCode,
|
||||
response.body().query().loginToken(), Locale.getDefault().getLanguage(), new LoginCallback() {
|
||||
@Override
|
||||
public void success(@NonNull LoginResult result) {
|
||||
Timber.d("Login Success");
|
||||
onLoginSuccess(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void twoFactorPrompt(@NonNull Throwable caught,
|
||||
@Nullable String token) {
|
||||
Timber.d("Requesting 2FA prompt");
|
||||
hideProgress();
|
||||
askUserForTwoFactorAuth();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void passwordResetPrompt(@Nullable String token) {
|
||||
Timber.d("Showing password reset prompt");
|
||||
hideProgress();
|
||||
showPasswordResetPrompt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(@NonNull Throwable caught) {
|
||||
Timber.e(caught);
|
||||
hideProgress();
|
||||
showMessageAndCancelDialog(caught.getLocalizedMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<MwQueryResponse> call, Throwable t) {
|
||||
Timber.e(t);
|
||||
showMessageAndCancelDialog(t.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) {
|
||||
if (!progressDialog.isShowing()) {
|
||||
// no longer attached to activity!
|
||||
return;
|
||||
}
|
||||
compositeDisposable.clear();
|
||||
sessionManager.setUserLoggedIn(true);
|
||||
AppAdapter.get().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();
|
||||
twoFactorContainer.setVisibility(VISIBLE);
|
||||
twoFactorEdit.setVisibility(VISIBLE);
|
||||
twoFactorEdit.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) {
|
||||
errorMessage.setText(getString(resId));
|
||||
errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
|
||||
errorMessageContainer.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
private void showMessage(String message, @ColorRes int colorResId) {
|
||||
errorMessage.setText(message);
|
||||
errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
|
||||
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 = usernameEdit.getText().length() != 0 && passwordEdit.getText().length() != 0
|
||||
&& (BuildConfig.DEBUG || twoFactorEdit.getText().length() != 0 || twoFactorEdit.getVisibility() != VISIBLE);
|
||||
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,errorMessage.getText().toString()); //Save the errorMessage
|
||||
outState.putString(saveUsername,getUsername()); // Save the username
|
||||
outState.putString(savePassword,getPassword()); // Save the password
|
||||
}
|
||||
private String getUsername() {
|
||||
return usernameEdit.getText().toString();
|
||||
}
|
||||
private String getPassword(){
|
||||
return passwordEdit.getText().toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(final Bundle savedInstanceState) {
|
||||
super.onRestoreInstanceState(savedInstanceState);
|
||||
usernameEdit.setText(savedInstanceState.getString(saveUsername));
|
||||
passwordEdit.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
404
app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
Normal file
404
app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
package fr.free.nrw.commons.auth
|
||||
|
||||
import android.accounts.AccountAuthenticatorActivity
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.NavUtils
|
||||
import androidx.core.content.ContextCompat
|
||||
import fr.free.nrw.commons.BuildConfig
|
||||
import fr.free.nrw.commons.CommonsApplication
|
||||
import fr.free.nrw.commons.R
|
||||
import fr.free.nrw.commons.Utils
|
||||
import fr.free.nrw.commons.auth.login.LoginCallback
|
||||
import fr.free.nrw.commons.auth.login.LoginClient
|
||||
import fr.free.nrw.commons.auth.login.LoginResult
|
||||
import fr.free.nrw.commons.contributions.MainActivity
|
||||
import fr.free.nrw.commons.databinding.ActivityLoginBinding
|
||||
import fr.free.nrw.commons.di.ApplicationlessInjection
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore
|
||||
import fr.free.nrw.commons.utils.AbstractTextWatcher
|
||||
import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags
|
||||
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
|
||||
import fr.free.nrw.commons.utils.SystemThemeUtils
|
||||
import fr.free.nrw.commons.utils.ViewUtil.hideKeyboard
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
||||
class LoginActivity : AccountAuthenticatorActivity() {
|
||||
@Inject
|
||||
lateinit var sessionManager: SessionManager
|
||||
|
||||
@Inject
|
||||
@field:Named("default_preferences")
|
||||
lateinit var applicationKvStore: JsonKvStore
|
||||
|
||||
@Inject
|
||||
lateinit var loginClient: LoginClient
|
||||
|
||||
@Inject
|
||||
lateinit var systemThemeUtils: SystemThemeUtils
|
||||
|
||||
private var binding: ActivityLoginBinding? = null
|
||||
private var progressDialog: ProgressDialog? = null
|
||||
private val textWatcher = AbstractTextWatcher(::onTextChanged)
|
||||
private val compositeDisposable = CompositeDisposable()
|
||||
private val delegate: AppCompatDelegate by lazy {
|
||||
AppCompatDelegate.create(this, null)
|
||||
}
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
ApplicationlessInjection
|
||||
.getInstance(this.applicationContext)
|
||||
.commonsApplicationComponent
|
||||
.inject(this)
|
||||
|
||||
val isDarkTheme = systemThemeUtils.isDeviceInNightMode()
|
||||
setTheme(if (isDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme)
|
||||
delegate.installViewFactory()
|
||||
delegate.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||
with(binding!!) {
|
||||
setContentView(root)
|
||||
|
||||
loginUsername.addTextChangedListener(textWatcher)
|
||||
loginPassword.addTextChangedListener(textWatcher)
|
||||
loginTwoFactor.addTextChangedListener(textWatcher)
|
||||
|
||||
skipLogin.setOnClickListener { skipLogin() }
|
||||
forgotPassword.setOnClickListener { forgotPassword() }
|
||||
aboutPrivacyPolicy.setOnClickListener { onPrivacyPolicyClicked() }
|
||||
signUpButton.setOnClickListener { signUp() }
|
||||
loginButton.setOnClickListener { performLogin() }
|
||||
loginPassword.setOnEditorActionListener(::onEditorAction)
|
||||
|
||||
loginPassword.onFocusChangeListener =
|
||||
View.OnFocusChangeListener(::onPasswordFocusChanged)
|
||||
|
||||
if (isBetaFlavour) {
|
||||
loginCredentials.text = getString(R.string.login_credential)
|
||||
} else {
|
||||
loginCredentials.visibility = View.GONE
|
||||
}
|
||||
|
||||
intent.getStringExtra(CommonsApplication.LOGIN_MESSAGE_INTENT_KEY)?.let {
|
||||
showMessage(it, R.color.secondaryDarkColor)
|
||||
}
|
||||
|
||||
intent.getStringExtra(CommonsApplication.LOGIN_USERNAME_INTENT_KEY)?.let {
|
||||
loginUsername.setText(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
delegate.onPostCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (sessionManager.currentAccount != null && sessionManager.isUserLoggedIn) {
|
||||
applicationKvStore.putBoolean("login_skipped", false)
|
||||
startMainActivity()
|
||||
}
|
||||
|
||||
if (applicationKvStore.getBoolean("login_skipped", false)) {
|
||||
performSkipLogin()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
compositeDisposable.clear()
|
||||
try {
|
||||
// To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
|
||||
if (progressDialog?.isShowing == true) {
|
||||
progressDialog!!.dismiss()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
with(binding!!) {
|
||||
loginUsername.removeTextChangedListener(textWatcher)
|
||||
loginPassword.removeTextChangedListener(textWatcher)
|
||||
loginTwoFactor.removeTextChangedListener(textWatcher)
|
||||
}
|
||||
delegate.onDestroy()
|
||||
loginClient.cancel()
|
||||
binding = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
delegate.onStart()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
delegate.onStop()
|
||||
}
|
||||
|
||||
override fun onPostResume() {
|
||||
super.onPostResume()
|
||||
delegate.onPostResume()
|
||||
}
|
||||
|
||||
override fun setContentView(view: View, params: ViewGroup.LayoutParams) {
|
||||
delegate.setContentView(view, params)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
NavUtils.navigateUpFromSameTask(this)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
// if progressDialog is visible during the configuration change then store state as true else false so that
|
||||
// we maintain visibility of progressDialog after configuration change
|
||||
if (progressDialog != null && progressDialog!!.isShowing) {
|
||||
outState.putBoolean(SAVE_PROGRESS_DIALOG, true)
|
||||
} else {
|
||||
outState.putBoolean(SAVE_PROGRESS_DIALOG, false)
|
||||
}
|
||||
outState.putString(
|
||||
SAVE_ERROR_MESSAGE,
|
||||
binding!!.errorMessage.text.toString()
|
||||
) //Save the errorMessage
|
||||
outState.putString(
|
||||
SAVE_USERNAME,
|
||||
binding!!.loginUsername.text.toString()
|
||||
) // Save the username
|
||||
outState.putString(
|
||||
SAVE_PASSWORD,
|
||||
binding!!.loginPassword.text.toString()
|
||||
) // Save the password
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME))
|
||||
binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD))
|
||||
if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) {
|
||||
performLogin()
|
||||
}
|
||||
val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE)
|
||||
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 SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state"
|
||||
const val SAVE_ERROR_MESSAGE: String = "errorMessage"
|
||||
const val SAVE_USERNAME: String = "username"
|
||||
const val SAVE_PASSWORD: String = "password"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
package fr.free.nrw.commons.auth;
|
||||
|
||||
|
||||
import org.wikipedia.dataclient.Service;
|
||||
import org.wikipedia.dataclient.mwapi.MwPostResponse;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Singleton;
|
||||
|
||||
import io.reactivex.Observable;
|
||||
|
||||
/**
|
||||
* Handler for logout
|
||||
*/
|
||||
@Singleton
|
||||
public class LogoutClient {
|
||||
|
||||
private final Service service;
|
||||
|
||||
@Inject
|
||||
public LogoutClient(@Named("commons-service") Service service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the CSRF token and uses that to post the logout api call
|
||||
* @return
|
||||
*/
|
||||
public Observable<MwPostResponse> postLogout() {
|
||||
return service.getCsrfToken().concatMap(tokenResponse -> service.postLogout(
|
||||
Objects.requireNonNull(Objects.requireNonNull(tokenResponse.query()).csrfToken())));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +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 org.wikipedia.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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Clears existing accounts from account manager
|
||||
* 2. Calls MediaWikiApi's logout function to clear cookies
|
||||
* @return
|
||||
*/
|
||||
public Completable logout() {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
|
||||
return Completable.fromObservable(Observable.fromArray(allAccounts)
|
||||
.map(a -> accountManager.removeAccount(a, null, null).getResult()))
|
||||
.doOnComplete(() -> {
|
||||
currentAccount = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a corresponding boolean preference
|
||||
*
|
||||
* @param key
|
||||
* @return
|
||||
*/
|
||||
public boolean getPreference(String key) {
|
||||
return defaultKvStore.getBoolean(key);
|
||||
}
|
||||
}
|
||||
95
app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
Normal file
95
app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package fr.free.nrw.commons.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
|
||||
import fr.free.nrw.commons.auth.login.LoginResult
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.Observable
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Manage the current logged in user session.
|
||||
*/
|
||||
@Singleton
|
||||
class SessionManager @Inject constructor(
|
||||
private val context: Context,
|
||||
@param:Named("default_preferences") private val defaultKvStore: JsonKvStore
|
||||
) {
|
||||
private val accountManager: AccountManager get() = AccountManager.get(context)
|
||||
|
||||
private var _currentAccount: Account? = null // Unlike a savings account... ;-)
|
||||
val currentAccount: Account? get() {
|
||||
if (_currentAccount == null) {
|
||||
val allAccounts = AccountManager.get(context).getAccountsByType(ACCOUNT_TYPE)
|
||||
if (allAccounts.isNotEmpty()) {
|
||||
_currentAccount = allAccounts[0]
|
||||
}
|
||||
}
|
||||
return _currentAccount
|
||||
}
|
||||
|
||||
val userName: String?
|
||||
get() = currentAccount?.name
|
||||
|
||||
var password: String?
|
||||
get() = currentAccount?.let { accountManager.getPassword(it) }
|
||||
private set(value) {
|
||||
currentAccount?.let { accountManager.setPassword(it, value) }
|
||||
}
|
||||
|
||||
val isUserLoggedIn: Boolean
|
||||
get() = defaultKvStore.getBoolean("isUserLoggedIn", false)
|
||||
|
||||
fun updateAccount(result: LoginResult) {
|
||||
if (createAccount(result.userName!!, result.password!!)) {
|
||||
password = result.password
|
||||
}
|
||||
}
|
||||
|
||||
fun doesAccountExist(): Boolean =
|
||||
currentAccount != null
|
||||
|
||||
fun setUserLoggedIn(isLoggedIn: Boolean) =
|
||||
defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn)
|
||||
|
||||
fun forceLogin(context: Context?) =
|
||||
context?.let { LoginActivity.startYourself(it) }
|
||||
|
||||
fun getPreference(key: String): Boolean =
|
||||
defaultKvStore.getBoolean(key)
|
||||
|
||||
fun logout(): Completable = Completable.fromObservable(
|
||||
Observable.empty<Any>()
|
||||
.doOnComplete {
|
||||
removeAccount()
|
||||
_currentAccount = null
|
||||
}
|
||||
)
|
||||
|
||||
private fun createAccount(userName: String, password: String): Boolean {
|
||||
var account = currentAccount
|
||||
if (account == null || TextUtils.isEmpty(account.name) || account.name != userName) {
|
||||
removeAccount()
|
||||
account = Account(userName, ACCOUNT_TYPE)
|
||||
return accountManager.addAccountExplicitly(account, password, null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun removeAccount() {
|
||||
currentAccount?.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
accountManager.removeAccountExplicitly(it)
|
||||
} else {
|
||||
accountManager.removeAccount(it, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
package fr.free.nrw.commons.auth;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
Normal file
75
app/src/main/java/fr/free/nrw/commons/auth/SignupActivity.kt
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package fr.free.nrw.commons.auth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import fr.free.nrw.commons.BuildConfig
|
||||
import fr.free.nrw.commons.R
|
||||
import fr.free.nrw.commons.theme.BaseActivity
|
||||
import timber.log.Timber
|
||||
|
||||
class SignupActivity : BaseActivity() {
|
||||
private var webView: WebView? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Timber.d("Signup Activity started")
|
||||
|
||||
webView = WebView(this)
|
||||
with(webView!!) {
|
||||
setContentView(this)
|
||||
webViewClient = MyWebViewClient()
|
||||
// Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
|
||||
// trust Wikimedia's site... right?
|
||||
settings.javaScriptEnabled = true
|
||||
loadUrl(BuildConfig.SIGNUP_LANDING_URL)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (webView!!.canGoBack()) {
|
||||
webView!!.goBack()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Known bug in androidx.appcompat library version 1.1.0 being tracked here
|
||||
* https://issuetracker.google.com/issues/141132133
|
||||
* App tries to put light/dark theme to webview and crashes in the process
|
||||
* This code tries to prevent applying the theme when sdk is between api 21 to 25
|
||||
*/
|
||||
override fun applyOverrideConfiguration(overrideConfiguration: Configuration) {
|
||||
if (Build.VERSION.SDK_INT <= 25 &&
|
||||
(resources.configuration.uiMode == applicationContext.resources.configuration.uiMode)
|
||||
) return
|
||||
super.applyOverrideConfiguration(overrideConfiguration)
|
||||
}
|
||||
|
||||
private inner class MyWebViewClient : WebViewClient() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean =
|
||||
if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) {
|
||||
//Signup success, so clear cookies, notify user, and load LoginActivity again
|
||||
Timber.d("Overriding URL %s", url)
|
||||
|
||||
Toast.makeText(
|
||||
this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
// terminate on task completion.
|
||||
finish()
|
||||
true
|
||||
} else {
|
||||
//If user clicks any other links in the webview
|
||||
Timber.d("Not overriding URL, URL is: %s", url)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package fr.free.nrw.commons.auth
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.NetworkErrorException
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.os.bundleOf
|
||||
import fr.free.nrw.commons.BuildConfig
|
||||
|
||||
private val SYNC_AUTHORITIES = arrayOf(
|
||||
BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles WikiMedia commons account Authentication
|
||||
*/
|
||||
class WikiAccountAuthenticator(
|
||||
private val context: Context
|
||||
) : AbstractAccountAuthenticator(context) {
|
||||
/**
|
||||
* Provides Bundle with edited Account Properties
|
||||
*/
|
||||
override fun editProperties(
|
||||
response: AccountAuthenticatorResponse,
|
||||
accountType: String
|
||||
) = bundleOf("test" to "editProperties")
|
||||
|
||||
// account type not supported returns bundle without loginActivity Intent, it just contains "test" key
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun addAccount(
|
||||
response: AccountAuthenticatorResponse,
|
||||
accountType: String,
|
||||
authTokenType: String?,
|
||||
requiredFeatures: Array<String>?,
|
||||
options: Bundle?
|
||||
) = if (BuildConfig.ACCOUNT_TYPE == accountType) {
|
||||
addAccount(response)
|
||||
} else {
|
||||
bundleOf("test" to "addAccount")
|
||||
}
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun confirmCredentials(
|
||||
response: AccountAuthenticatorResponse, account: Account, options: Bundle?
|
||||
) = bundleOf("test" to "confirmCredentials")
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun getAuthToken(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
authTokenType: String,
|
||||
options: Bundle?
|
||||
) = bundleOf("test" to "getAuthToken")
|
||||
|
||||
override fun getAuthTokenLabel(authTokenType: String) =
|
||||
if (BuildConfig.ACCOUNT_TYPE == authTokenType) AUTH_TOKEN_TYPE else null
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun updateCredentials(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
authTokenType: String?,
|
||||
options: Bundle?
|
||||
) = bundleOf("test" to "updateCredentials")
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun hasFeatures(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account, features: Array<String>
|
||||
) = bundleOf(AccountManager.KEY_BOOLEAN_RESULT to false)
|
||||
|
||||
/**
|
||||
* Provides a bundle containing a Parcel
|
||||
* the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
|
||||
*/
|
||||
private fun addAccount(response: AccountAuthenticatorResponse): Bundle {
|
||||
val intent = Intent(context, LoginActivity::class.java)
|
||||
.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
|
||||
return bundleOf(AccountManager.KEY_INTENT to intent)
|
||||
}
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun getAccountRemovalAllowed(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?
|
||||
): Bundle {
|
||||
val result = super.getAccountRemovalAllowed(response, account)
|
||||
|
||||
if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
|
||||
&& !result.containsKey(AccountManager.KEY_INTENT)
|
||||
) {
|
||||
val allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
|
||||
|
||||
if (allowed) {
|
||||
for (auth in SYNC_AUTHORITIES) {
|
||||
ContentResolver.cancelSync(account, auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package fr.free.nrw.commons.auth.csrf
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import fr.free.nrw.commons.auth.SessionManager
|
||||
import fr.free.nrw.commons.auth.login.LoginCallback
|
||||
import fr.free.nrw.commons.auth.login.LoginClient
|
||||
import fr.free.nrw.commons.auth.login.LoginFailedException
|
||||
import fr.free.nrw.commons.auth.login.LoginResult
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
|
||||
import retrofit2.Call
|
||||
import retrofit2.Response
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.Executors.newSingleThreadExecutor
|
||||
|
||||
class CsrfTokenClient(
|
||||
private val sessionManager: SessionManager,
|
||||
private val csrfTokenInterface: CsrfTokenInterface,
|
||||
private val loginClient: LoginClient,
|
||||
private val logoutClient: LogoutClient,
|
||||
) {
|
||||
private var retries = 0
|
||||
private var csrfTokenCall: Call<MwQueryResponse?>? = null
|
||||
|
||||
@Throws(Throwable::class)
|
||||
fun getTokenBlocking(): String {
|
||||
var token = ""
|
||||
val userName = sessionManager.userName ?: ""
|
||||
val password = sessionManager.password ?: ""
|
||||
|
||||
for (retry in 0 until MAX_RETRIES_OF_LOGIN_BLOCKING) {
|
||||
try {
|
||||
if (retry > 0) {
|
||||
// Log in explicitly
|
||||
loginClient.loginBlocking(userName, password, "")
|
||||
}
|
||||
|
||||
// Get CSRFToken response off the main thread.
|
||||
val response =
|
||||
newSingleThreadExecutor()
|
||||
.submit(
|
||||
Callable {
|
||||
csrfTokenInterface.getCsrfTokenCall().execute()
|
||||
},
|
||||
).get()
|
||||
|
||||
if (response
|
||||
.body()
|
||||
?.query()
|
||||
?.csrfToken()
|
||||
.isNullOrEmpty()
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
token = response.body()!!.query()!!.csrfToken()!!
|
||||
if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) {
|
||||
throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
|
||||
}
|
||||
break
|
||||
} catch (e: LoginFailedException) {
|
||||
throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
|
||||
} catch (t: Throwable) {
|
||||
Timber.w(t)
|
||||
}
|
||||
}
|
||||
|
||||
if (token.isEmpty() || token == ANON_TOKEN) {
|
||||
throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun request(
|
||||
service: CsrfTokenInterface,
|
||||
cb: Callback,
|
||||
): Call<MwQueryResponse?> =
|
||||
requestToken(
|
||||
service,
|
||||
object : Callback {
|
||||
override fun success(token: String?) {
|
||||
if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) {
|
||||
retryWithLogin(cb) {
|
||||
InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
|
||||
}
|
||||
} else {
|
||||
cb.success(token)
|
||||
}
|
||||
}
|
||||
|
||||
override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught }
|
||||
|
||||
override fun twoFactorPrompt() = cb.twoFactorPrompt()
|
||||
},
|
||||
)
|
||||
|
||||
@VisibleForTesting
|
||||
fun requestToken(
|
||||
service: CsrfTokenInterface,
|
||||
cb: Callback,
|
||||
): Call<MwQueryResponse?> {
|
||||
val call = service.getCsrfTokenCall()
|
||||
call.enqueue(
|
||||
object : retrofit2.Callback<MwQueryResponse?> {
|
||||
override fun onResponse(
|
||||
call: Call<MwQueryResponse?>,
|
||||
response: Response<MwQueryResponse?>,
|
||||
) {
|
||||
if (call.isCanceled) {
|
||||
return
|
||||
}
|
||||
cb.success(response.body()!!.query()!!.csrfToken())
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call<MwQueryResponse?>,
|
||||
t: Throwable,
|
||||
) {
|
||||
if (call.isCanceled) {
|
||||
return
|
||||
}
|
||||
cb.failure(t)
|
||||
}
|
||||
},
|
||||
)
|
||||
return call
|
||||
}
|
||||
|
||||
private fun retryWithLogin(
|
||||
callback: Callback,
|
||||
caught: () -> Throwable?,
|
||||
) {
|
||||
val userName = sessionManager.userName
|
||||
val password = sessionManager.password
|
||||
if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) {
|
||||
retries++
|
||||
logoutClient.logout()
|
||||
login(userName, password, callback) {
|
||||
Timber.i("retrying...")
|
||||
cancel()
|
||||
csrfTokenCall = request(csrfTokenInterface, callback)
|
||||
}
|
||||
} else {
|
||||
callback.failure(caught())
|
||||
}
|
||||
}
|
||||
|
||||
private fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
callback: Callback,
|
||||
retryCallback: () -> Unit,
|
||||
) = loginClient.request(
|
||||
username,
|
||||
password,
|
||||
object : LoginCallback {
|
||||
override fun success(loginResult: LoginResult) {
|
||||
if (loginResult.pass) {
|
||||
sessionManager.updateAccount(loginResult)
|
||||
retryCallback()
|
||||
} else {
|
||||
callback.failure(LoginFailedException(loginResult.message))
|
||||
}
|
||||
}
|
||||
|
||||
override fun twoFactorPrompt(
|
||||
caught: Throwable,
|
||||
token: String?,
|
||||
) = callback.twoFactorPrompt()
|
||||
|
||||
// Should not happen here, but call the callback just in case.
|
||||
override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password."))
|
||||
|
||||
override fun error(caught: Throwable) = callback.failure(caught)
|
||||
},
|
||||
)
|
||||
|
||||
private fun cancel() {
|
||||
loginClient.cancel()
|
||||
if (csrfTokenCall != null) {
|
||||
csrfTokenCall!!.cancel()
|
||||
csrfTokenCall = null
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun success(token: String?)
|
||||
|
||||
fun failure(caught: Throwable?)
|
||||
|
||||
fun twoFactorPrompt()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ANON_TOKEN = "+\\"
|
||||
private const val MAX_RETRIES = 1
|
||||
private const val MAX_RETRIES_OF_LOGIN_BLOCKING = 2
|
||||
const val INVALID_TOKEN_ERROR_MESSAGE = "Invalid token, or login failure."
|
||||
const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token."
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidLoginTokenException(
|
||||
message: String,
|
||||
) : Exception(message)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package fr.free.nrw.commons.auth.csrf
|
||||
|
||||
import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Headers
|
||||
|
||||
interface CsrfTokenInterface {
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@GET(MW_API_PREFIX + "action=query&meta=tokens&type=csrf")
|
||||
fun getCsrfTokenCall(): Call<MwQueryResponse?>
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package fr.free.nrw.commons.auth.csrf
|
||||
|
||||
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage
|
||||
import javax.inject.Inject
|
||||
|
||||
class LogoutClient
|
||||
@Inject
|
||||
constructor(
|
||||
private val store: CommonsCookieStorage,
|
||||
) {
|
||||
fun logout() = store.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package fr.free.nrw.commons.auth.login
|
||||
|
||||
interface LoginCallback {
|
||||
fun success(loginResult: LoginResult)
|
||||
|
||||
fun twoFactorPrompt(
|
||||
caught: Throwable,
|
||||
token: String?,
|
||||
)
|
||||
|
||||
fun passwordResetPrompt(token: String?)
|
||||
|
||||
fun error(caught: Throwable)
|
||||
}
|
||||
258
app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
Normal file
258
app/src/main/java/fr/free/nrw/commons/auth/login/LoginClient.kt
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package fr.free.nrw.commons.auth.login
|
||||
|
||||
import android.text.TextUtils
|
||||
import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
|
||||
import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
|
||||
import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Responsible for making login related requests to the server.
|
||||
*/
|
||||
class LoginClient(
|
||||
private val loginInterface: LoginInterface,
|
||||
) {
|
||||
private var tokenCall: Call<MwQueryResponse?>? = null
|
||||
private var loginCall: Call<LoginResponse?>? = null
|
||||
|
||||
/**
|
||||
* userLanguage
|
||||
* It holds the value of the user's device language code.
|
||||
* For example, if user's device language is English it will hold En
|
||||
* The value will be fetched when the user clicks Login Button in the LoginActivity
|
||||
*/
|
||||
private var userLanguage = ""
|
||||
|
||||
private fun getLoginToken() = loginInterface.getLoginToken()
|
||||
|
||||
fun request(
|
||||
userName: String,
|
||||
password: String,
|
||||
cb: LoginCallback,
|
||||
) {
|
||||
cancel()
|
||||
|
||||
tokenCall = getLoginToken()
|
||||
tokenCall!!.enqueue(
|
||||
object : Callback<MwQueryResponse?> {
|
||||
override fun onResponse(
|
||||
call: Call<MwQueryResponse?>,
|
||||
response: Response<MwQueryResponse?>,
|
||||
) {
|
||||
login(
|
||||
userName,
|
||||
password,
|
||||
null,
|
||||
null,
|
||||
response.body()!!.query()!!.loginToken(),
|
||||
userLanguage,
|
||||
cb,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call<MwQueryResponse?>,
|
||||
caught: Throwable,
|
||||
) {
|
||||
if (call.isCanceled) {
|
||||
return
|
||||
}
|
||||
cb.error(caught)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun login(
|
||||
userName: String,
|
||||
password: String,
|
||||
retypedPassword: String?,
|
||||
twoFactorCode: String?,
|
||||
loginToken: String?,
|
||||
userLanguage: String,
|
||||
cb: LoginCallback,
|
||||
) {
|
||||
this.userLanguage = userLanguage
|
||||
|
||||
loginCall =
|
||||
if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) {
|
||||
loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
|
||||
} else {
|
||||
loginInterface.postLogIn(
|
||||
userName,
|
||||
password,
|
||||
retypedPassword,
|
||||
twoFactorCode,
|
||||
loginToken,
|
||||
userLanguage,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
loginCall!!.enqueue(
|
||||
object : Callback<LoginResponse?> {
|
||||
override fun onResponse(
|
||||
call: Call<LoginResponse?>,
|
||||
response: Response<LoginResponse?>,
|
||||
) {
|
||||
val loginResult = response.body()?.toLoginResult(password)
|
||||
if (loginResult != null) {
|
||||
if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) {
|
||||
// The server could do some transformations on user names, e.g. on some
|
||||
// wikis is uppercases the first letter.
|
||||
getExtendedInfo(loginResult.userName, loginResult, cb)
|
||||
} else if ("UI" == loginResult.status) {
|
||||
when (loginResult) {
|
||||
is OAuthResult ->
|
||||
cb.twoFactorPrompt(
|
||||
LoginFailedException(loginResult.message),
|
||||
loginToken,
|
||||
)
|
||||
|
||||
is ResetPasswordResult -> cb.passwordResetPrompt(loginToken)
|
||||
|
||||
is LoginResult.Result ->
|
||||
cb.error(
|
||||
LoginFailedException(loginResult.message),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
cb.error(LoginFailedException(loginResult.message))
|
||||
}
|
||||
} else {
|
||||
cb.error(IOException("Login failed. Unexpected response."))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call<LoginResponse?>,
|
||||
t: Throwable,
|
||||
) {
|
||||
if (call.isCanceled) {
|
||||
return
|
||||
}
|
||||
cb.error(t)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun doLogin(
|
||||
username: String,
|
||||
password: String,
|
||||
twoFactorCode: String,
|
||||
userLanguage: String,
|
||||
loginCallback: LoginCallback,
|
||||
) {
|
||||
getLoginToken().enqueue(
|
||||
object : Callback<MwQueryResponse?> {
|
||||
override fun onResponse(
|
||||
call: Call<MwQueryResponse?>,
|
||||
response: Response<MwQueryResponse?>,
|
||||
) = if (response.isSuccessful) {
|
||||
val loginToken = response.body()?.query()?.loginToken()
|
||||
loginToken?.let {
|
||||
login(username, password, null, twoFactorCode, it, userLanguage, loginCallback)
|
||||
} ?: run {
|
||||
loginCallback.error(IOException("Failed to retrieve login token"))
|
||||
}
|
||||
} else {
|
||||
loginCallback.error(IOException("Failed to retrieve login token"))
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call<MwQueryResponse?>,
|
||||
t: Throwable,
|
||||
) {
|
||||
loginCallback.error(t)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(Throwable::class)
|
||||
fun loginBlocking(
|
||||
userName: String,
|
||||
password: String,
|
||||
twoFactorCode: String?,
|
||||
) {
|
||||
val tokenResponse = getLoginToken().execute()
|
||||
if (tokenResponse
|
||||
.body()
|
||||
?.query()
|
||||
?.loginToken()
|
||||
.isNullOrEmpty()
|
||||
) {
|
||||
throw IOException("Unexpected response when getting login token.")
|
||||
}
|
||||
|
||||
val loginToken = tokenResponse.body()?.query()?.loginToken()
|
||||
val tempLoginCall =
|
||||
if (twoFactorCode.isNullOrEmpty()) {
|
||||
loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
|
||||
} else {
|
||||
loginInterface.postLogIn(
|
||||
userName,
|
||||
password,
|
||||
null,
|
||||
twoFactorCode,
|
||||
loginToken,
|
||||
userLanguage,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
val response = tempLoginCall.execute()
|
||||
val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.")
|
||||
val loginResult = loginResponse.toLoginResult(password) ?: throw IOException("Unexpected response when logging in.")
|
||||
|
||||
if ("UI" == loginResult.status) {
|
||||
if (loginResult is OAuthResult) {
|
||||
// TODO: Find a better way to boil up the warning about 2FA
|
||||
throw LoginFailedException(loginResult.message)
|
||||
}
|
||||
throw LoginFailedException(loginResult.message)
|
||||
}
|
||||
|
||||
if (!loginResult.pass || TextUtils.isEmpty(loginResult.userName)) {
|
||||
throw LoginFailedException(loginResult.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getExtendedInfo(
|
||||
userName: String,
|
||||
loginResult: LoginResult,
|
||||
cb: LoginCallback,
|
||||
) = loginInterface
|
||||
.getUserInfo(userName)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ response: MwQueryResponse? ->
|
||||
loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
|
||||
loginResult.groups =
|
||||
response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet()
|
||||
cb.success(loginResult)
|
||||
}, { caught: Throwable ->
|
||||
Timber.e(caught, "Login succeeded but getting group information failed. ")
|
||||
cb.error(caught)
|
||||
})
|
||||
|
||||
fun cancel() {
|
||||
tokenCall?.let {
|
||||
it.cancel()
|
||||
tokenCall = null
|
||||
}
|
||||
|
||||
loginCall?.let {
|
||||
it.cancel()
|
||||
loginCall = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package fr.free.nrw.commons.auth.login
|
||||
|
||||
class LoginFailedException(
|
||||
message: String?,
|
||||
) : Throwable(message)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package fr.free.nrw.commons.auth.login
|
||||
|
||||
import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
|
||||
import io.reactivex.Observable
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface LoginInterface {
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@GET(MW_API_PREFIX + "action=query&meta=tokens&type=login")
|
||||
fun getLoginToken(): Call<MwQueryResponse?>
|
||||
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@FormUrlEncoded
|
||||
@POST(MW_API_PREFIX + "action=clientlogin&rememberMe=")
|
||||
fun postLogIn(
|
||||
@Field("username") user: String?,
|
||||
@Field("password") pass: String?,
|
||||
@Field("logintoken") token: String?,
|
||||
@Field("uselang") userLanguage: String?,
|
||||
@Field("loginreturnurl") url: String?,
|
||||
): Call<LoginResponse?>
|
||||
|
||||
@Headers("Cache-Control: no-cache")
|
||||
@FormUrlEncoded
|
||||
@POST(MW_API_PREFIX + "action=clientlogin&rememberMe=")
|
||||
fun postLogIn(
|
||||
@Field("username") user: String?,
|
||||
@Field("password") pass: String?,
|
||||
@Field("retype") retypedPass: String?,
|
||||
@Field("OATHToken") twoFactorCode: String?,
|
||||
@Field("logintoken") token: String?,
|
||||
@Field("uselang") userLanguage: String?,
|
||||
@Field("logincontinue") loginContinue: Boolean,
|
||||
): Call<LoginResponse?>
|
||||
|
||||
@GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate")
|
||||
fun getUserInfo(
|
||||
@Query("ususers") userName: String,
|
||||
): Observable<MwQueryResponse?>
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package fr.free.nrw.commons.auth.login
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult
|
||||
import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
|
||||
import fr.free.nrw.commons.auth.login.LoginResult.Result
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwServiceError
|
||||
|
||||
class LoginResponse {
|
||||
@SerializedName("error")
|
||||
val error: MwServiceError? = null
|
||||
|
||||
@SerializedName("clientlogin")
|
||||
private val clientLogin: ClientLogin? = null
|
||||
|
||||
fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password)
|
||||
}
|
||||
|
||||
internal class ClientLogin {
|
||||
private val status: String? = null
|
||||
private val requests: List<Request>? = null
|
||||
private val message: String? = null
|
||||
|
||||
@SerializedName("username")
|
||||
private val userName: String? = null
|
||||
|
||||
fun toLoginResult(password: String): LoginResult {
|
||||
var userMessage = message
|
||||
if ("UI" == status) {
|
||||
if (requests != null) {
|
||||
for (req in requests) {
|
||||
if ("MediaWiki\\Extension\\OATHAuth\\Auth\\TOTPAuthenticationRequest" == req.id()) {
|
||||
return OAuthResult(status, userName, password, message)
|
||||
} else if ("MediaWiki\\Auth\\PasswordAuthenticationRequest" == req.id()) {
|
||||
return ResetPasswordResult(status, userName, password, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ("PASS" != status && "FAIL" != status) {
|
||||
// TODO: String resource -- Looks like needed for others in this class too
|
||||
userMessage = "An unknown error occurred."
|
||||
}
|
||||
return Result(status ?: "", userName, password, userMessage)
|
||||
}
|
||||
}
|
||||
|
||||
internal class Request {
|
||||
private val id: String? = null
|
||||
private val required: String? = null
|
||||
private val provider: String? = null
|
||||
private val account: String? = null
|
||||
private val fields: Map<String, RequestField>? = null
|
||||
|
||||
fun id(): String? = id
|
||||
}
|
||||
|
||||
internal class RequestField {
|
||||
private val type: String? = null
|
||||
private val label: String? = null
|
||||
private val help: String? = null
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package fr.free.nrw.commons.auth.login
|
||||
|
||||
sealed class LoginResult(
|
||||
val status: String,
|
||||
val userName: String?,
|
||||
val password: String?,
|
||||
val message: String?,
|
||||
) {
|
||||
var userId = 0
|
||||
var groups = emptySet<String>()
|
||||
val pass: Boolean get() = "PASS" == status
|
||||
|
||||
class Result(
|
||||
status: String,
|
||||
userName: String?,
|
||||
password: String?,
|
||||
message: String?,
|
||||
) : LoginResult(status, userName, password, message)
|
||||
|
||||
class OAuthResult(
|
||||
status: String,
|
||||
userName: String?,
|
||||
password: String?,
|
||||
message: String?,
|
||||
) : LoginResult(status, userName, password, message)
|
||||
|
||||
class ResetPasswordResult(
|
||||
status: String,
|
||||
userName: String?,
|
||||
password: String?,
|
||||
message: String?,
|
||||
) : LoginResult(status, userName, password, message)
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package fr.free.nrw.commons.bookmarks
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
class Bookmark(mediaName: String?, mediaCreator: String?,
|
||||
/**
|
||||
* Modifies the content URI - marking this bookmark as already saved in the database
|
||||
* @param contentUri the content URI
|
||||
*/
|
||||
var contentUri: Uri?) {
|
||||
/**
|
||||
* Gets the content URI for this bookmark
|
||||
* @return content URI
|
||||
*/
|
||||
/**
|
||||
* Gets the media name
|
||||
* @return the media name
|
||||
*/
|
||||
val mediaName: String = mediaName ?: ""
|
||||
/**
|
||||
* Gets media creator
|
||||
* @return creator name
|
||||
*/
|
||||
val mediaCreator: String = mediaCreator ?: ""
|
||||
|
||||
}
|
||||
|
|
@ -5,23 +5,15 @@ import android.view.LayoutInflater;
|
|||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import android.widget.FrameLayout;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
|
||||
import fr.free.nrw.commons.contributions.MainActivity;
|
||||
import fr.free.nrw.commons.databinding.FragmentBookmarksBinding;
|
||||
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
|
||||
import fr.free.nrw.commons.explore.ParentViewPager;
|
||||
import fr.free.nrw.commons.kvstore.JsonKvStore;
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import javax.inject.Inject;
|
||||
|
||||
import butterknife.BindView;
|
||||
import butterknife.ButterKnife;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.contributions.ContributionController;
|
||||
import javax.inject.Named;
|
||||
|
||||
|
|
@ -29,12 +21,7 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
|
|||
|
||||
private FragmentManager supportFragmentManager;
|
||||
private BookmarksPagerAdapter adapter;
|
||||
@BindView(R.id.viewPagerBookmarks)
|
||||
ParentViewPager viewPager;
|
||||
@BindView(R.id.tab_layout)
|
||||
TabLayout tabLayout;
|
||||
@BindView(R.id.fragmentContainer)
|
||||
FrameLayout fragmentContainer;
|
||||
FragmentBookmarksBinding binding;
|
||||
|
||||
@Inject
|
||||
ContributionController controller;
|
||||
|
|
@ -54,7 +41,9 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
|
|||
}
|
||||
|
||||
public void setScroll(boolean canScroll) {
|
||||
viewPager.setCanScroll(canScroll);
|
||||
if (binding!=null) {
|
||||
binding.viewPagerBookmarks.setCanScroll(canScroll);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -68,8 +57,7 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
|
|||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
super.onCreateView(inflater, container, savedInstanceState);
|
||||
View view = inflater.inflate(R.layout.fragment_bookmarks, container, false);
|
||||
ButterKnife.bind(this, view);
|
||||
binding = FragmentBookmarksBinding.inflate(inflater, container, false);
|
||||
|
||||
// Activity can call methods in the fragment by acquiring a
|
||||
// reference to the Fragment from FragmentManager, using findFragmentById()
|
||||
|
|
@ -77,14 +65,14 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
|
|||
|
||||
adapter = new BookmarksPagerAdapter(supportFragmentManager, getContext(),
|
||||
applicationKvStore.getBoolean("login_skipped"));
|
||||
viewPager.setAdapter(adapter);
|
||||
tabLayout.setupWithViewPager(viewPager);
|
||||
binding.viewPagerBookmarks.setAdapter(adapter);
|
||||
binding.tabLayout.setupWithViewPager(binding.viewPagerBookmarks);
|
||||
|
||||
((MainActivity) getActivity()).showTabs();
|
||||
((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||
|
||||
setupTabLayout();
|
||||
return view;
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -92,15 +80,15 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
|
|||
* visibility of tabLayout to gone.
|
||||
*/
|
||||
public void setupTabLayout() {
|
||||
tabLayout.setVisibility(View.VISIBLE);
|
||||
binding.tabLayout.setVisibility(View.VISIBLE);
|
||||
if (adapter.getCount() == 1) {
|
||||
tabLayout.setVisibility(View.GONE);
|
||||
binding.tabLayout.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void onBackPressed() {
|
||||
if (((BookmarkListRootFragment) (adapter.getItem(tabLayout.getSelectedTabPosition())))
|
||||
if (((BookmarkListRootFragment) (adapter.getItem(binding.tabLayout.getSelectedTabPosition())))
|
||||
.backPressed()) {
|
||||
// The event is handled internally by the adapter , no further action required.
|
||||
return;
|
||||
|
|
@ -108,4 +96,10 @@ public class BookmarkFragment extends CommonsDaggerSupportFragment {
|
|||
// Event is not handled by the adapter ( performed back action ) change action bar.
|
||||
((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
binding = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,31 +2,30 @@ package fr.free.nrw.commons.bookmarks;
|
|||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.FrameLayout;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import butterknife.BindView;
|
||||
import butterknife.ButterKnife;
|
||||
import fr.free.nrw.commons.Media;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesFragment;
|
||||
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.category.CategoryImagesCallback;
|
||||
import fr.free.nrw.commons.category.GridViewAdapter;
|
||||
import fr.free.nrw.commons.contributions.MainActivity;
|
||||
import fr.free.nrw.commons.databinding.FragmentFeaturedRootBinding;
|
||||
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
|
||||
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
|
||||
import fr.free.nrw.commons.navtab.NavTab;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements
|
||||
FragmentManager.OnBackStackChangedListener,
|
||||
|
|
@ -39,8 +38,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
public Fragment listFragment;
|
||||
private BookmarksPagerAdapter bookmarksPagerAdapter;
|
||||
|
||||
@BindView(R.id.explore_container)
|
||||
FrameLayout container;
|
||||
FragmentFeaturedRootBinding binding;
|
||||
|
||||
public BookmarkListRootFragment() {
|
||||
//empty constructor necessary otherwise crashes on recreate
|
||||
|
|
@ -50,14 +48,21 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
String title = bundle.getString("categoryName");
|
||||
int order = bundle.getInt("order");
|
||||
final int orderItem = bundle.getInt("orderItem");
|
||||
if (order == 0) {
|
||||
listFragment = new BookmarkPicturesFragment();
|
||||
} else {
|
||||
listFragment = new BookmarkLocationsFragment();
|
||||
|
||||
switch (order){
|
||||
case 0: listFragment = new BookmarkPicturesFragment();
|
||||
break;
|
||||
|
||||
case 1: listFragment = new BookmarkLocationsFragment();
|
||||
break;
|
||||
|
||||
case 3: listFragment = new BookmarkCategoriesFragment();
|
||||
break;
|
||||
}
|
||||
if(orderItem == 2) {
|
||||
listFragment = new BookmarkItemsFragment();
|
||||
}
|
||||
}
|
||||
|
||||
Bundle featuredArguments = new Bundle();
|
||||
featuredArguments.putString("categoryName", title);
|
||||
listFragment.setArguments(featuredArguments);
|
||||
|
|
@ -70,9 +75,8 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
View view = inflater.inflate(R.layout.fragment_featured_root, container, false);
|
||||
ButterKnife.bind(this, view);
|
||||
return view;
|
||||
binding = FragmentFeaturedRootBinding.inflate(inflater, container, false);
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -132,7 +136,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
|
||||
@Override
|
||||
public void onMediaClicked(int position) {
|
||||
Log.d("deneme8", "on media clicked");
|
||||
Timber.d("on media clicked");
|
||||
/*container.setVisibility(View.VISIBLE);
|
||||
((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE);
|
||||
mediaDetails = new MediaDetailPagerFragment(false, true, position);
|
||||
|
|
@ -184,7 +188,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
public void refreshNominatedMedia(int index) {
|
||||
if (mediaDetails != null && !listFragment.isVisible()) {
|
||||
removeFragment(mediaDetails);
|
||||
mediaDetails = new MediaDetailPagerFragment(false, true);
|
||||
mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
|
||||
((BookmarkFragment) getParentFragment()).setScroll(false);
|
||||
setFragment(mediaDetails, listFragment);
|
||||
mediaDetails.showImage(index);
|
||||
|
|
@ -206,10 +210,6 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
//check mediaDetailPage fragment is not null then we check mediaDetail.is Visible or not to avoid NullPointerException
|
||||
if (mediaDetails != null) {
|
||||
if (mediaDetails.isVisible()) {
|
||||
if (mediaDetails.backButtonClicked()) {
|
||||
// mediaDetails handled the back clicked , no further action required.
|
||||
return true;
|
||||
}
|
||||
// todo add get list fragment
|
||||
((BookmarkFragment) getParentFragment()).setupTabLayout();
|
||||
ArrayList<Integer> removed = mediaDetails.getRemovedItems();
|
||||
|
|
@ -244,10 +244,10 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
|
||||
Log.d("deneme8", "on media clicked");
|
||||
container.setVisibility(View.VISIBLE);
|
||||
((BookmarkFragment) getParentFragment()).tabLayout.setVisibility(View.GONE);
|
||||
mediaDetails = new MediaDetailPagerFragment(false, true);
|
||||
Timber.d("on media clicked");
|
||||
binding.exploreContainer.setVisibility(View.VISIBLE);
|
||||
((BookmarkFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE);
|
||||
mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
|
||||
((BookmarkFragment) getParentFragment()).setScroll(false);
|
||||
setFragment(mediaDetails, listFragment);
|
||||
mediaDetails.showImage(position);
|
||||
|
|
@ -257,4 +257,10 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
|
|||
public void onBackStackChanged() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
binding = 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -12,7 +12,6 @@ import androidx.fragment.app.FragmentPagerAdapter;
|
|||
import java.util.ArrayList;
|
||||
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment;
|
||||
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment;
|
||||
|
||||
public class BookmarksPagerAdapter extends FragmentPagerAdapter {
|
||||
|
|
@ -50,6 +49,13 @@ public class BookmarksPagerAdapter extends FragmentPagerAdapter {
|
|||
new BookmarkListRootFragment(locationBundle, this),
|
||||
context.getString(R.string.title_page_bookmarks_items)));
|
||||
}
|
||||
final Bundle categoriesBundle = new Bundle();
|
||||
categoriesBundle.putString("categoryName",
|
||||
context.getString(R.string.title_page_bookmarks_categories));
|
||||
categoriesBundle.putInt("order", 3);
|
||||
pages.add(new BookmarkPages(
|
||||
new BookmarkListRootFragment(categoriesBundle, this),
|
||||
context.getString(R.string.title_page_bookmarks_categories)));
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package fr.free.nrw.commons.bookmarks.category
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Bookmark categories dao
|
||||
*
|
||||
* @constructor Create empty Bookmark categories dao
|
||||
*/
|
||||
@Dao
|
||||
interface BookmarkCategoriesDao {
|
||||
|
||||
/**
|
||||
* Insert or Delete category bookmark into DB
|
||||
*
|
||||
* @param bookmarksCategoryModal
|
||||
*/
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(bookmarksCategoryModal: BookmarksCategoryModal)
|
||||
|
||||
|
||||
/**
|
||||
* Delete category bookmark from DB
|
||||
*
|
||||
* @param bookmarksCategoryModal
|
||||
*/
|
||||
@Delete
|
||||
suspend fun delete(bookmarksCategoryModal: BookmarksCategoryModal)
|
||||
|
||||
/**
|
||||
* Checks if given category exist in DB
|
||||
*
|
||||
* @param categoryName
|
||||
* @return
|
||||
*/
|
||||
@Query("SELECT EXISTS (SELECT 1 FROM bookmarks_categories WHERE categoryName = :categoryName)")
|
||||
suspend fun doesExist(categoryName: String): Boolean
|
||||
|
||||
/**
|
||||
* Get all categories
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@Query("SELECT * FROM bookmarks_categories")
|
||||
fun getAllCategories(): Flow<List<BookmarksCategoryModal>>
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package fr.free.nrw.commons.bookmarks.category
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.android.support.DaggerFragment
|
||||
import fr.free.nrw.commons.R
|
||||
import fr.free.nrw.commons.category.CategoryDetailsActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Tab fragment to show list of bookmarked Categories
|
||||
*/
|
||||
class BookmarkCategoriesFragment : DaggerFragment() {
|
||||
|
||||
@Inject
|
||||
lateinit var bookmarkCategoriesDao: BookmarkCategoriesDao
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return ComposeView(requireContext()).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 listOfBookmarks by bookmarkCategoriesDao.getAllCategories()
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (listOfBookmarks.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.bookmark_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSystemInDarkTheme()) Color(0xB3FFFFFF)
|
||||
else Color(
|
||||
0x8A000000
|
||||
)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = listOfBookmarks) { bookmarkItem ->
|
||||
CategoryItem(
|
||||
categoryName = bookmarkItem.categoryName,
|
||||
onClick = {
|
||||
val categoryDetailsIntent = Intent(
|
||||
requireContext(),
|
||||
CategoryDetailsActivity::class.java
|
||||
).putExtra("categoryName", it)
|
||||
startActivity(categoryDetailsIntent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun CategoryItem(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (String) -> Unit,
|
||||
categoryName: String
|
||||
) {
|
||||
Row(modifier = modifier.clickable {
|
||||
onClick(categoryName)
|
||||
}) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Image(
|
||||
modifier = Modifier.size(48.dp),
|
||||
painter = painterResource(R.drawable.commons),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = categoryName,
|
||||
maxLines = 2,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CategoryItemPreview() {
|
||||
CategoryItem(
|
||||
onClick = {},
|
||||
categoryName = "Test Category"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package fr.free.nrw.commons.bookmarks.category
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Data class representing bookmarked category in DB
|
||||
*
|
||||
* @property categoryName
|
||||
* @constructor Create empty Bookmarks category modal
|
||||
*/
|
||||
@Entity(tableName = "bookmarks_categories")
|
||||
data class BookmarksCategoryModal(
|
||||
@PrimaryKey val categoryName: String
|
||||
)
|
||||
|
|
@ -15,25 +15,34 @@ import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
|
|||
/**
|
||||
* Helps to inflate Wikidata Items into Items tab
|
||||
*/
|
||||
class BookmarkItemsAdapter (val list: List<DepictedItem>, val context: Context) :
|
||||
RecyclerView.Adapter<BookmarkItemsAdapter.BookmarkItemViewHolder>() {
|
||||
|
||||
class BookmarkItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
class BookmarkItemsAdapter(
|
||||
val list: List<DepictedItem>,
|
||||
val context: Context,
|
||||
) : RecyclerView.Adapter<BookmarkItemsAdapter.BookmarkItemViewHolder>() {
|
||||
class BookmarkItemViewHolder(
|
||||
itemView: View,
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label)
|
||||
var description: TextView = itemView.findViewById(R.id.description)
|
||||
var depictsImage: SimpleDraweeView = itemView.findViewById(R.id.depicts_image)
|
||||
var layout : ConstraintLayout = itemView.findViewById(R.id.layout_item)
|
||||
var layout: ConstraintLayout = itemView.findViewById(R.id.layout_item)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookmarkItemViewHolder {
|
||||
val v: View = LayoutInflater.from(context)
|
||||
.inflate(R.layout.item_depictions, parent, false)
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int,
|
||||
): BookmarkItemViewHolder {
|
||||
val v: View =
|
||||
LayoutInflater
|
||||
.from(context)
|
||||
.inflate(R.layout.item_depictions, parent, false)
|
||||
return BookmarkItemViewHolder(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BookmarkItemViewHolder, position: Int) {
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: BookmarkItemViewHolder,
|
||||
position: Int,
|
||||
) {
|
||||
val depictedItem = list[position]
|
||||
holder.depictsLabel.text = depictedItem.name
|
||||
holder.description.text = depictedItem.description
|
||||
|
|
@ -48,7 +57,5 @@ class BookmarkItemsAdapter (val list: List<DepictedItem>, val context: Context)
|
|||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return list.size
|
||||
}
|
||||
}
|
||||
override fun getItemCount(): Int = list.size
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -309,22 +311,18 @@ public class BookmarkItemsDao {
|
|||
if (from == to) {
|
||||
return;
|
||||
}
|
||||
if (from < 7) {
|
||||
if (from < 18) {
|
||||
// doesn't exist yet
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
|
||||
if (from == 7) {
|
||||
if (from == 18) {
|
||||
// table added in version 19
|
||||
onCreate(db);
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
|
||||
if (from == 8) {
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,9 @@ import androidx.annotation.NonNull;
|
|||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import butterknife.BindView;
|
||||
import butterknife.ButterKnife;
|
||||
import dagger.android.support.DaggerFragment;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.databinding.FragmentBookmarksItemsBinding;
|
||||
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
|
||||
import java.util.List;
|
||||
import javax.inject.Inject;
|
||||
|
|
@ -26,17 +25,7 @@ import org.jetbrains.annotations.NotNull;
|
|||
*/
|
||||
public class BookmarkItemsFragment extends DaggerFragment {
|
||||
|
||||
@BindView(R.id.status_message)
|
||||
TextView statusTextView;
|
||||
|
||||
@BindView(R.id.loading_images_progress_bar)
|
||||
ProgressBar progressBar;
|
||||
|
||||
@BindView(R.id.list_view)
|
||||
RecyclerView recyclerView;
|
||||
|
||||
@BindView(R.id.parent_layout)
|
||||
RelativeLayout parentLayout;
|
||||
private FragmentBookmarksItemsBinding binding;
|
||||
|
||||
@Inject
|
||||
BookmarkItemsController controller;
|
||||
|
|
@ -51,16 +40,13 @@ public class BookmarkItemsFragment extends DaggerFragment {
|
|||
final ViewGroup container,
|
||||
final Bundle savedInstanceState
|
||||
) {
|
||||
final View v = inflater.inflate(R.layout.fragment_bookmarks_items, container, false);
|
||||
ButterKnife.bind(this, v);
|
||||
return v;
|
||||
binding = FragmentBookmarksItemsBinding.inflate(inflater, container, false);
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(final @NotNull View view, @Nullable final Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
initList(requireContext());
|
||||
}
|
||||
|
||||
|
|
@ -77,13 +63,19 @@ public class BookmarkItemsFragment extends DaggerFragment {
|
|||
private void initList(final Context context) {
|
||||
final List<DepictedItem> depictItems = controller.loadFavoritesItems();
|
||||
final BookmarkItemsAdapter adapter = new BookmarkItemsAdapter(depictItems, context);
|
||||
recyclerView.setAdapter(adapter);
|
||||
progressBar.setVisibility(View.GONE);
|
||||
binding.listView.setAdapter(adapter);
|
||||
binding.loadingImagesProgressBar.setVisibility(View.GONE);
|
||||
if (depictItems.isEmpty()) {
|
||||
statusTextView.setText(R.string.bookmark_empty);
|
||||
statusTextView.setVisibility(View.VISIBLE);
|
||||
binding.statusMessage.setText(R.string.bookmark_empty);
|
||||
binding.statusMessage.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
statusTextView.setVisibility(View.GONE);
|
||||
binding.statusMessage.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
binding = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -46,11 +47,11 @@ public class BookmarkLocationsDao {
|
|||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
cursor = db.query(
|
||||
BookmarkLocationsContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
null,
|
||||
new String[]{},
|
||||
null);
|
||||
BookmarkLocationsContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
null,
|
||||
new String[]{},
|
||||
null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
items.add(fromCursor(cursor));
|
||||
}
|
||||
|
|
@ -126,11 +127,11 @@ public class BookmarkLocationsDao {
|
|||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
cursor = db.query(
|
||||
BookmarkLocationsContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
Table.COLUMN_NAME + "=?",
|
||||
new String[]{bookmarkLocation.name},
|
||||
null);
|
||||
BookmarkLocationsContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
Table.COLUMN_NAME + "=?",
|
||||
new String[]{bookmarkLocation.name},
|
||||
null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -146,10 +147,11 @@ 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)),
|
||||
cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LONG)), 1F);
|
||||
cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LONG)), 1F);
|
||||
|
||||
final Sitelinks.Builder builder = new Sitelinks.Builder();
|
||||
builder.setWikipediaLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_WIKIPEDIA_LINK)));
|
||||
|
|
@ -175,8 +177,8 @@ public class BookmarkLocationsDao {
|
|||
cv.put(BookmarkLocationsDao.Table.COLUMN_LANGUAGE, bookmarkLocation.getLanguage());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_DESCRIPTION, bookmarkLocation.getLongDescription());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_CATEGORY, bookmarkLocation.getCategory());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_TEXT, bookmarkLocation.getLabel().getText());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_ICON, bookmarkLocation.getLabel().getIcon());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_TEXT, bookmarkLocation.getLabel()!=null ? bookmarkLocation.getLabel().getText() : "");
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_ICON, bookmarkLocation.getLabel()!=null ? bookmarkLocation.getLabel().getIcon() : null);
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIPEDIA_LINK, bookmarkLocation.siteLinks.getWikipediaLink().toString());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIDATA_LINK, bookmarkLocation.siteLinks.getWikidataLink().toString());
|
||||
cv.put(BookmarkLocationsDao.Table.COLUMN_COMMONS_LINK, bookmarkLocation.siteLinks.getCommonsLink().toString());
|
||||
|
|
@ -207,40 +209,40 @@ public class BookmarkLocationsDao {
|
|||
|
||||
// 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_LANGUAGE,
|
||||
COLUMN_DESCRIPTION,
|
||||
COLUMN_CATEGORY,
|
||||
COLUMN_LABEL_TEXT,
|
||||
COLUMN_LABEL_ICON,
|
||||
COLUMN_LAT,
|
||||
COLUMN_LONG,
|
||||
COLUMN_IMAGE_URL,
|
||||
COLUMN_WIKIPEDIA_LINK,
|
||||
COLUMN_WIKIDATA_LINK,
|
||||
COLUMN_COMMONS_LINK,
|
||||
COLUMN_PIC,
|
||||
COLUMN_EXISTS,
|
||||
COLUMN_NAME,
|
||||
COLUMN_LANGUAGE,
|
||||
COLUMN_DESCRIPTION,
|
||||
COLUMN_CATEGORY,
|
||||
COLUMN_LABEL_TEXT,
|
||||
COLUMN_LABEL_ICON,
|
||||
COLUMN_LAT,
|
||||
COLUMN_LONG,
|
||||
COLUMN_IMAGE_URL,
|
||||
COLUMN_WIKIPEDIA_LINK,
|
||||
COLUMN_WIKIDATA_LINK,
|
||||
COLUMN_COMMONS_LINK,
|
||||
COLUMN_PIC,
|
||||
COLUMN_EXISTS,
|
||||
};
|
||||
|
||||
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 PRIMARY KEY,"
|
||||
+ COLUMN_LANGUAGE + " STRING,"
|
||||
+ COLUMN_DESCRIPTION + " STRING,"
|
||||
+ COLUMN_CATEGORY + " STRING,"
|
||||
+ COLUMN_LABEL_TEXT + " STRING,"
|
||||
+ COLUMN_LABEL_ICON + " INTEGER,"
|
||||
+ COLUMN_LAT + " DOUBLE,"
|
||||
+ COLUMN_LONG + " DOUBLE,"
|
||||
+ COLUMN_IMAGE_URL + " STRING,"
|
||||
+ COLUMN_WIKIPEDIA_LINK + " STRING,"
|
||||
+ COLUMN_WIKIDATA_LINK + " STRING,"
|
||||
+ COLUMN_COMMONS_LINK + " STRING,"
|
||||
+ COLUMN_PIC + " STRING,"
|
||||
+ COLUMN_EXISTS + " STRING"
|
||||
+ ");";
|
||||
+ COLUMN_NAME + " STRING PRIMARY KEY,"
|
||||
+ COLUMN_LANGUAGE + " STRING,"
|
||||
+ COLUMN_DESCRIPTION + " STRING,"
|
||||
+ COLUMN_CATEGORY + " STRING,"
|
||||
+ COLUMN_LABEL_TEXT + " STRING,"
|
||||
+ COLUMN_LABEL_ICON + " INTEGER,"
|
||||
+ COLUMN_LAT + " DOUBLE,"
|
||||
+ COLUMN_LONG + " DOUBLE,"
|
||||
+ COLUMN_IMAGE_URL + " STRING,"
|
||||
+ COLUMN_WIKIPEDIA_LINK + " STRING,"
|
||||
+ COLUMN_WIKIDATA_LINK + " STRING,"
|
||||
+ COLUMN_COMMONS_LINK + " STRING,"
|
||||
+ COLUMN_PIC + " STRING,"
|
||||
+ COLUMN_EXISTS + " STRING"
|
||||
+ ");";
|
||||
|
||||
public static void onCreate(SQLiteDatabase db) {
|
||||
db.execSQL(CREATE_TABLE_STATEMENT);
|
||||
|
|
@ -308,4 +310,4 @@ public class BookmarkLocationsDao {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,33 @@
|
|||
package fr.free.nrw.commons.bookmarks.locations;
|
||||
|
||||
import android.Manifest.permission;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
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;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import butterknife.BindView;
|
||||
import butterknife.ButterKnife;
|
||||
import dagger.android.support.DaggerFragment;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.contributions.ContributionController;
|
||||
import fr.free.nrw.commons.databinding.FragmentBookmarksLocationsBinding;
|
||||
import fr.free.nrw.commons.nearby.Place;
|
||||
import fr.free.nrw.commons.nearby.fragments.CommonPlaceClickActions;
|
||||
import fr.free.nrw.commons.nearby.fragments.PlaceAdapter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.inject.Inject;
|
||||
import kotlin.Unit;
|
||||
|
||||
public class BookmarkLocationsFragment extends DaggerFragment {
|
||||
|
||||
@BindView(R.id.statusMessage) TextView statusTextView;
|
||||
@BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar;
|
||||
@BindView(R.id.listView) RecyclerView recyclerView;
|
||||
@BindView(R.id.parentLayout) RelativeLayout parentLayout;
|
||||
public FragmentBookmarksLocationsBinding binding;
|
||||
|
||||
@Inject BookmarkLocationsController controller;
|
||||
@Inject ContributionController contributionController;
|
||||
|
|
@ -37,6 +35,42 @@ public class BookmarkLocationsFragment extends DaggerFragment {
|
|||
@Inject CommonPlaceClickActions commonPlaceClickActions;
|
||||
private PlaceAdapter adapter;
|
||||
|
||||
private final ActivityResultLauncher<Intent> cameraPickLauncherForResult =
|
||||
registerForActivityResult(new StartActivityForResult(),
|
||||
result -> {
|
||||
contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
|
||||
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks);
|
||||
});
|
||||
});
|
||||
|
||||
private final ActivityResultLauncher<Intent> galleryPickLauncherForResult =
|
||||
registerForActivityResult(new StartActivityForResult(),
|
||||
result -> {
|
||||
contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
|
||||
contributionController.onPictureReturnedFromGallery(result, requireActivity(), callbacks);
|
||||
});
|
||||
});
|
||||
|
||||
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<Map<String, Boolean>>() {
|
||||
@Override
|
||||
public void onActivityResult(Map<String, Boolean> result) {
|
||||
boolean areAllGranted = true;
|
||||
for(final boolean b : result.values()) {
|
||||
areAllGranted = areAllGranted && b;
|
||||
}
|
||||
|
||||
if (areAllGranted) {
|
||||
contributionController.locationPermissionCallback.onLocationPermissionGranted();
|
||||
} else {
|
||||
if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
|
||||
contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult);
|
||||
} else {
|
||||
contributionController.locationPermissionCallback.onLocationPermissionDenied(getActivity().getString(R.string.in_app_camera_location_permission_denied));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create an instance of the fragment with the right bundle parameters
|
||||
* @return an instance of the fragment
|
||||
|
|
@ -51,25 +85,27 @@ public class BookmarkLocationsFragment extends DaggerFragment {
|
|||
ViewGroup container,
|
||||
Bundle savedInstanceState
|
||||
) {
|
||||
View v = inflater.inflate(R.layout.fragment_bookmarks_locations, container, false);
|
||||
ButterKnife.bind(this, v);
|
||||
return v;
|
||||
binding = FragmentBookmarksLocationsBinding.inflate(inflater, container, false);
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
binding.loadingImagesProgressBar.setVisibility(View.VISIBLE);
|
||||
binding.listView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
adapter = new PlaceAdapter(bookmarkLocationDao,
|
||||
place -> Unit.INSTANCE,
|
||||
(place, isBookmarked) -> {
|
||||
adapter.remove(place);
|
||||
return Unit.INSTANCE;
|
||||
},
|
||||
commonPlaceClickActions
|
||||
commonPlaceClickActions,
|
||||
inAppCameraLocationPermissionLauncher,
|
||||
galleryPickLauncherForResult,
|
||||
cameraPickLauncherForResult
|
||||
);
|
||||
recyclerView.setAdapter(adapter);
|
||||
binding.listView.setAdapter(adapter);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -84,17 +120,18 @@ public class BookmarkLocationsFragment extends DaggerFragment {
|
|||
private void initList() {
|
||||
List<Place> places = controller.loadFavoritesLocations();
|
||||
adapter.setItems(places);
|
||||
progressBar.setVisibility(View.GONE);
|
||||
binding.loadingImagesProgressBar.setVisibility(View.GONE);
|
||||
if (places.size() <= 0) {
|
||||
statusTextView.setText(R.string.bookmark_empty);
|
||||
statusTextView.setVisibility(View.VISIBLE);
|
||||
binding.statusMessage.setText(R.string.bookmark_empty);
|
||||
binding.statusMessage.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
statusTextView.setVisibility(View.GONE);
|
||||
binding.statusMessage.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
contributionController.handleActivityResult(getActivity(), requestCode, resultCode, data);
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
binding = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package fr.free.nrw.commons.bookmarks.models
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
class Bookmark(
|
||||
mediaName: String?,
|
||||
mediaCreator: String?,
|
||||
/**
|
||||
* Gets or Sets the content URI - marking this bookmark as already saved in the database
|
||||
* @return content URI
|
||||
* @param contentUri the content URI
|
||||
*/
|
||||
var contentUri: Uri?,
|
||||
) {
|
||||
/**
|
||||
* Gets the media name
|
||||
* @return the media name
|
||||
*/
|
||||
val mediaName: String = mediaName ?: ""
|
||||
|
||||
/**
|
||||
* Gets media creator
|
||||
* @return creator name
|
||||
*/
|
||||
val mediaCreator: String = mediaCreator ?: ""
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package fr.free.nrw.commons.bookmarks.pictures;
|
||||
|
||||
import fr.free.nrw.commons.Media;
|
||||
import fr.free.nrw.commons.bookmarks.Bookmark;
|
||||
import fr.free.nrw.commons.bookmarks.models.Bookmark;
|
||||
import fr.free.nrw.commons.media.MediaClient;
|
||||
import io.reactivex.Observable;
|
||||
import io.reactivex.ObservableSource;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -16,7 +17,7 @@ import javax.inject.Named;
|
|||
import javax.inject.Provider;
|
||||
import javax.inject.Singleton;
|
||||
|
||||
import fr.free.nrw.commons.bookmarks.Bookmark;
|
||||
import fr.free.nrw.commons.bookmarks.models.Bookmark;
|
||||
|
||||
import static fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider.BASE_URI;
|
||||
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -9,20 +9,15 @@ import android.view.LayoutInflater;
|
|||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.GridView;
|
||||
import android.widget.ListAdapter;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import butterknife.BindView;
|
||||
import butterknife.ButterKnife;
|
||||
import dagger.android.support.DaggerFragment;
|
||||
import fr.free.nrw.commons.Media;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.bookmarks.BookmarkListRootFragment;
|
||||
import fr.free.nrw.commons.category.GridViewAdapter;
|
||||
import fr.free.nrw.commons.databinding.FragmentBookmarksPicturesBinding;
|
||||
import fr.free.nrw.commons.utils.NetworkUtils;
|
||||
import fr.free.nrw.commons.utils.ViewUtil;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
|
|
@ -37,11 +32,7 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
private GridViewAdapter gridAdapter;
|
||||
private CompositeDisposable compositeDisposable = new CompositeDisposable();
|
||||
|
||||
@BindView(R.id.statusMessage) TextView statusTextView;
|
||||
@BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar;
|
||||
@BindView(R.id.bookmarkedPicturesList) GridView gridView;
|
||||
@BindView(R.id.parentLayout) RelativeLayout parentLayout;
|
||||
|
||||
private FragmentBookmarksPicturesBinding binding;
|
||||
@Inject
|
||||
BookmarkPicturesController controller;
|
||||
|
||||
|
|
@ -59,15 +50,14 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
ViewGroup container,
|
||||
Bundle savedInstanceState
|
||||
) {
|
||||
View v = inflater.inflate(R.layout.fragment_bookmarks_pictures, container, false);
|
||||
ButterKnife.bind(this, v);
|
||||
return v;
|
||||
binding = FragmentBookmarksPicturesBinding.inflate(inflater, container, false);
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
gridView.setOnItemClickListener((AdapterView.OnItemClickListener) getParentFragment());
|
||||
binding.bookmarkedPicturesList.setOnItemClickListener((AdapterView.OnItemClickListener) getParentFragment());
|
||||
initList();
|
||||
}
|
||||
|
||||
|
|
@ -81,13 +71,14 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
compositeDisposable.clear();
|
||||
binding = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (controller.needRefreshBookmarkedPictures()) {
|
||||
gridView.setVisibility(GONE);
|
||||
binding.bookmarkedPicturesList.setVisibility(GONE);
|
||||
if (gridAdapter != null) {
|
||||
gridAdapter.clear();
|
||||
((BookmarkListRootFragment)getParentFragment()).viewPagerNotifyDataSetChanged();
|
||||
|
|
@ -107,8 +98,8 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
return;
|
||||
}
|
||||
|
||||
progressBar.setVisibility(VISIBLE);
|
||||
statusTextView.setVisibility(GONE);
|
||||
binding.loadingImagesProgressBar.setVisibility(VISIBLE);
|
||||
binding.statusMessage.setVisibility(GONE);
|
||||
|
||||
compositeDisposable.add(controller.loadBookmarkedPictures()
|
||||
.subscribeOn(Schedulers.io())
|
||||
|
|
@ -120,12 +111,12 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
* Handles the UI updates for no internet scenario
|
||||
*/
|
||||
private void handleNoInternet() {
|
||||
progressBar.setVisibility(GONE);
|
||||
binding.loadingImagesProgressBar.setVisibility(GONE);
|
||||
if (gridAdapter == null || gridAdapter.isEmpty()) {
|
||||
statusTextView.setVisibility(VISIBLE);
|
||||
statusTextView.setText(getString(R.string.no_internet));
|
||||
binding.statusMessage.setVisibility(VISIBLE);
|
||||
binding.statusMessage.setText(getString(R.string.no_internet));
|
||||
} else {
|
||||
ViewUtil.showShortSnackbar(parentLayout, R.string.no_internet);
|
||||
ViewUtil.showShortSnackbar(binding.parentLayout, R.string.no_internet);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +127,7 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
private void handleError(Throwable throwable) {
|
||||
Timber.e(throwable, "Error occurred while loading images inside a category");
|
||||
try{
|
||||
ViewUtil.showShortSnackbar(parentLayout, R.string.error_loading_images);
|
||||
ViewUtil.showShortSnackbar(binding.getRoot(), R.string.error_loading_images);
|
||||
initErrorView();
|
||||
}catch (Exception e){
|
||||
e.printStackTrace();
|
||||
|
|
@ -147,12 +138,12 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
* Handles the UI updates for a error scenario
|
||||
*/
|
||||
private void initErrorView() {
|
||||
progressBar.setVisibility(GONE);
|
||||
binding.loadingImagesProgressBar.setVisibility(GONE);
|
||||
if (gridAdapter == null || gridAdapter.isEmpty()) {
|
||||
statusTextView.setVisibility(VISIBLE);
|
||||
statusTextView.setText(getString(R.string.no_images_found));
|
||||
binding.statusMessage.setVisibility(VISIBLE);
|
||||
binding.statusMessage.setText(getString(R.string.no_images_found));
|
||||
} else {
|
||||
statusTextView.setVisibility(GONE);
|
||||
binding.statusMessage.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,12 +151,12 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
* Handles the UI updates when there is no bookmarks
|
||||
*/
|
||||
private void initEmptyBookmarkListView() {
|
||||
progressBar.setVisibility(GONE);
|
||||
binding.loadingImagesProgressBar.setVisibility(GONE);
|
||||
if (gridAdapter == null || gridAdapter.isEmpty()) {
|
||||
statusTextView.setVisibility(VISIBLE);
|
||||
statusTextView.setText(getString(R.string.bookmark_empty));
|
||||
binding.statusMessage.setVisibility(VISIBLE);
|
||||
binding.statusMessage.setText(getString(R.string.bookmark_empty));
|
||||
} else {
|
||||
statusTextView.setVisibility(GONE);
|
||||
binding.statusMessage.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,14 +179,18 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
setAdapter(collection);
|
||||
} else {
|
||||
if (gridAdapter.containsAll(collection)) {
|
||||
binding.loadingImagesProgressBar.setVisibility(GONE);
|
||||
binding.statusMessage.setVisibility(GONE);
|
||||
binding.bookmarkedPicturesList.setVisibility(VISIBLE);
|
||||
binding.bookmarkedPicturesList.setAdapter(gridAdapter);
|
||||
return;
|
||||
}
|
||||
gridAdapter.addItems(collection);
|
||||
((BookmarkListRootFragment) getParentFragment()).viewPagerNotifyDataSetChanged();
|
||||
}
|
||||
progressBar.setVisibility(GONE);
|
||||
statusTextView.setVisibility(GONE);
|
||||
gridView.setVisibility(VISIBLE);
|
||||
binding.loadingImagesProgressBar.setVisibility(GONE);
|
||||
binding.statusMessage.setVisibility(GONE);
|
||||
binding.bookmarkedPicturesList.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -208,7 +203,7 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
R.layout.layout_category_images,
|
||||
mediaList
|
||||
);
|
||||
gridView.setAdapter(gridAdapter);
|
||||
binding.bookmarkedPicturesList.setAdapter(gridAdapter);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -217,6 +212,7 @@ public class BookmarkPicturesFragment extends DaggerFragment {
|
|||
* @return GridView Adapter
|
||||
*/
|
||||
public ListAdapter getAdapter() {
|
||||
return gridView.getAdapter();
|
||||
return binding.bookmarkedPicturesList.getAdapter();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
package fr.free.nrw.commons.campaigns
|
||||
|
||||
/**
|
||||
* A data class to hold a campaign
|
||||
*/
|
||||
data class Campaign(var title: String? = null,
|
||||
var description: String? = null,
|
||||
var startDate: String? = null,
|
||||
var endDate: String? = null,
|
||||
var link: String? = null,
|
||||
var isWLMCampaign: Boolean = false)
|
||||
|
|
@ -8,6 +8,7 @@ import com.google.gson.annotations.SerializedName
|
|||
class CampaignConfig {
|
||||
@SerializedName("showOnlyLiveCampaigns")
|
||||
private val showOnlyLiveCampaigns = false
|
||||
|
||||
@SerializedName("sortBy")
|
||||
private val sortBy: String? = null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package fr.free.nrw.commons.campaigns
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import fr.free.nrw.commons.campaigns.models.Campaign
|
||||
|
||||
/**
|
||||
* Data class to hold the response from the campaigns api
|
||||
|
|
@ -8,7 +9,7 @@ import com.google.gson.annotations.SerializedName
|
|||
class CampaignResponseDTO {
|
||||
@SerializedName("config")
|
||||
val campaignConfig: CampaignConfig? = null
|
||||
|
||||
@SerializedName("campaigns")
|
||||
val campaigns: List<Campaign>? = null
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
package fr.free.nrw.commons.campaigns;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import fr.free.nrw.commons.theme.BaseActivity;
|
||||
import org.wikipedia.util.DateUtil;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.util.Date;
|
||||
|
||||
import butterknife.BindView;
|
||||
import butterknife.ButterKnife;
|
||||
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 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(campaignPreference, false);
|
||||
ViewUtil.showLongToast(getContext(),
|
||||
getResources().getString(R.string.nearby_campaign_dismiss_message));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void init() {
|
||||
final View rootView = inflate(getContext(), R.layout.layout_campagin, this);
|
||||
viewHolder = new ViewHolder(rootView);
|
||||
setOnClickListener(view -> {
|
||||
if (campaign != null) {
|
||||
if (campaign.isWLMCampaign()) {
|
||||
((MainActivity)(getContext())).showNearby();
|
||||
} else {
|
||||
Utils.handleWebUrl(getContext(), Uri.parse(campaign.getLink()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public class ViewHolder {
|
||||
|
||||
@BindView(R.id.iv_campaign)
|
||||
ImageView ivCampaign;
|
||||
@BindView(R.id.tv_title) TextView tvTitle;
|
||||
@BindView(R.id.tv_description) TextView tvDescription;
|
||||
@BindView(R.id.tv_dates) TextView tvDates;
|
||||
|
||||
public ViewHolder(View itemView) {
|
||||
ButterKnife.bind(this, itemView);
|
||||
}
|
||||
|
||||
public void init() {
|
||||
if (campaign != null) {
|
||||
ivCampaign.setImageDrawable(
|
||||
getResources().getDrawable(R.drawable.ic_campaign));
|
||||
|
||||
tvTitle.setText(campaign.getTitle());
|
||||
tvDescription.setText(campaign.getDescription());
|
||||
try {
|
||||
if (campaign.isWLMCampaign()) {
|
||||
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());
|
||||
tvDates.setText(String.format("%1s - %2s", DateUtil.getExtraShortDateString(startDate),
|
||||
DateUtil.getExtraShortDateString(endDate)));
|
||||
}
|
||||
} catch (final ParseException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt
Normal file
121
app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package fr.free.nrw.commons.campaigns
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import fr.free.nrw.commons.R
|
||||
import fr.free.nrw.commons.Utils
|
||||
import fr.free.nrw.commons.campaigns.models.Campaign
|
||||
import fr.free.nrw.commons.contributions.MainActivity
|
||||
import fr.free.nrw.commons.databinding.LayoutCampaginBinding
|
||||
import fr.free.nrw.commons.theme.BaseActivity
|
||||
import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort
|
||||
import fr.free.nrw.commons.utils.DateUtil.getExtraShortDateString
|
||||
import fr.free.nrw.commons.utils.SwipableCardView
|
||||
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
|
||||
import timber.log.Timber
|
||||
import java.text.ParseException
|
||||
|
||||
/**
|
||||
* A view which represents a single campaign
|
||||
*/
|
||||
class CampaignView : SwipableCardView {
|
||||
private var campaign: Campaign? = null
|
||||
private var binding: LayoutCampaginBinding? = null
|
||||
private var viewHolder: ViewHolder? = null
|
||||
private var campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
|
||||
context, attrs, defStyleAttr) {
|
||||
init()
|
||||
}
|
||||
|
||||
fun setCampaign(campaign: Campaign?) {
|
||||
this.campaign = campaign
|
||||
if (campaign != null) {
|
||||
if (campaign.isWLMCampaign) {
|
||||
campaignPreference = WLM_CARD_PREFERENCE
|
||||
}
|
||||
visibility = VISIBLE
|
||||
viewHolder!!.init()
|
||||
} else {
|
||||
visibility = GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSwipe(view: View): Boolean {
|
||||
view.visibility = GONE
|
||||
(context as BaseActivity).defaultKvStore.putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false)
|
||||
showLongToast(
|
||||
context,
|
||||
resources.getString(R.string.nearby_campaign_dismiss_message)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
binding = LayoutCampaginBinding.inflate(
|
||||
LayoutInflater.from(context), this, true
|
||||
)
|
||||
viewHolder = ViewHolder()
|
||||
setOnClickListener {
|
||||
campaign?.let {
|
||||
if (it.isWLMCampaign) {
|
||||
((context) as MainActivity).showNearby()
|
||||
} else {
|
||||
Utils.handleWebUrl(context, Uri.parse(it.link))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class ViewHolder {
|
||||
fun init() {
|
||||
if (campaign != null) {
|
||||
binding!!.ivCampaign.setImageDrawable(
|
||||
ContextCompat.getDrawable(binding!!.root.context, R.drawable.ic_campaign)
|
||||
)
|
||||
binding!!.tvTitle.text = campaign!!.title
|
||||
binding!!.tvDescription.text = campaign!!.description
|
||||
try {
|
||||
if (campaign!!.isWLMCampaign) {
|
||||
binding!!.tvDates.text = String.format(
|
||||
"%1s - %2s", campaign!!.startDate,
|
||||
campaign!!.endDate
|
||||
)
|
||||
} else {
|
||||
val startDate = getIso8601DateFormatShort().parse(
|
||||
campaign?.startDate
|
||||
)
|
||||
val endDate = getIso8601DateFormatShort().parse(
|
||||
campaign?.endDate
|
||||
)
|
||||
binding!!.tvDates.text = String.format(
|
||||
"%1s - %2s", getExtraShortDateString(
|
||||
startDate!!
|
||||
), getExtraShortDateString(endDate!!)
|
||||
)
|
||||
}
|
||||
} catch (e: ParseException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CAMPAIGNS_DEFAULT_PREFERENCE: String = "displayCampaignsCardView"
|
||||
const val WLM_CARD_PREFERENCE: String = "displayWLMCardView"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
package fr.free.nrw.commons.campaigns;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Singleton;
|
||||
|
||||
import fr.free.nrw.commons.BasePresenter;
|
||||
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
|
||||
import fr.free.nrw.commons.utils.CommonsDateUtil;
|
||||
import io.reactivex.Scheduler;
|
||||
import io.reactivex.Single;
|
||||
import io.reactivex.SingleObserver;
|
||||
import io.reactivex.disposables.Disposable;
|
||||
import timber.log.Timber;
|
||||
|
||||
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
|
||||
import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD;
|
||||
|
||||
/**
|
||||
* The presenter for the campaigns view, fetches the campaigns from the api and informs the view on
|
||||
* success and error
|
||||
*/
|
||||
@Singleton
|
||||
public class CampaignsPresenter implements BasePresenter<ICampaignsView> {
|
||||
private final OkHttpJsonApiClient okHttpJsonApiClient;
|
||||
private final Scheduler mainThreadScheduler;
|
||||
private final Scheduler ioScheduler;
|
||||
|
||||
private ICampaignsView view;
|
||||
private Disposable disposable;
|
||||
private Campaign campaign;
|
||||
|
||||
@Inject
|
||||
public CampaignsPresenter(OkHttpJsonApiClient okHttpJsonApiClient, @Named(IO_THREAD)Scheduler ioScheduler, @Named(MAIN_THREAD)Scheduler mainThreadScheduler) {
|
||||
this.okHttpJsonApiClient = okHttpJsonApiClient;
|
||||
this.mainThreadScheduler=mainThreadScheduler;
|
||||
this.ioScheduler=ioScheduler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttachView(ICampaignsView view) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
@Override public void onDetachView() {
|
||||
this.view = null;
|
||||
if (disposable != null) {
|
||||
disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* make the api call to fetch the campaigns
|
||||
*/
|
||||
@SuppressLint("CheckResult")
|
||||
public void getCampaigns() {
|
||||
if (view != null && okHttpJsonApiClient != null) {
|
||||
//If we already have a campaign, lets not make another call
|
||||
if (this.campaign != null) {
|
||||
view.showCampaigns(campaign);
|
||||
return;
|
||||
}
|
||||
Single<CampaignResponseDTO> campaigns = okHttpJsonApiClient.getCampaigns();
|
||||
campaigns.observeOn(mainThreadScheduler)
|
||||
.subscribeOn(ioScheduler)
|
||||
.subscribeWith(new SingleObserver<CampaignResponseDTO>() {
|
||||
|
||||
@Override public void onSubscribe(Disposable d) {
|
||||
disposable = d;
|
||||
}
|
||||
|
||||
@Override public void onSuccess(CampaignResponseDTO campaignResponseDTO) {
|
||||
List<Campaign> campaigns = campaignResponseDTO.getCampaigns();
|
||||
if (campaigns == null || campaigns.isEmpty()) {
|
||||
Timber.e("The campaigns list is empty");
|
||||
view.showCampaigns(null);
|
||||
return;
|
||||
}
|
||||
Collections.sort(campaigns, (campaign, t1) -> {
|
||||
Date date1, date2;
|
||||
try {
|
||||
|
||||
date1 = CommonsDateUtil.getIso8601DateFormatShort().parse(campaign.getStartDate());
|
||||
date2 = CommonsDateUtil.getIso8601DateFormatShort().parse(t1.getStartDate());
|
||||
} catch (ParseException e) {
|
||||
e.printStackTrace();
|
||||
return -1;
|
||||
}
|
||||
return date1.compareTo(date2);
|
||||
});
|
||||
Date campaignEndDate, campaignStartDate;
|
||||
Date currentDate = new Date();
|
||||
try {
|
||||
for (Campaign aCampaign : campaigns) {
|
||||
campaignEndDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getEndDate());
|
||||
campaignStartDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getStartDate());
|
||||
if (campaignEndDate.compareTo(currentDate) >= 0
|
||||
&& campaignStartDate.compareTo(currentDate) <= 0) {
|
||||
campaign = aCampaign;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (ParseException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
view.showCampaigns(campaign);
|
||||
}
|
||||
|
||||
@Override public void onError(Throwable e) {
|
||||
Timber.e(e, "could not fetch campaigns");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package fr.free.nrw.commons.campaigns
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import fr.free.nrw.commons.BasePresenter
|
||||
import fr.free.nrw.commons.campaigns.models.Campaign
|
||||
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
|
||||
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.MAIN_THREAD
|
||||
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
|
||||
import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort
|
||||
import io.reactivex.Scheduler
|
||||
import io.reactivex.disposables.Disposable
|
||||
import timber.log.Timber
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The presenter for the campaigns view, fetches the campaigns from the api and informs the view on
|
||||
* success and error
|
||||
*/
|
||||
@Singleton
|
||||
class CampaignsPresenter @Inject constructor(
|
||||
private val okHttpJsonApiClient: OkHttpJsonApiClient?,
|
||||
@param:Named(IO_THREAD) private val ioScheduler: Scheduler,
|
||||
@param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler
|
||||
) : BasePresenter<ICampaignsView?> {
|
||||
private var view: ICampaignsView? = null
|
||||
private var disposable: Disposable? = null
|
||||
private var campaign: Campaign? = null
|
||||
|
||||
override fun onAttachView(view: ICampaignsView) {
|
||||
this.view = view
|
||||
}
|
||||
|
||||
override fun onDetachView() {
|
||||
view = null
|
||||
disposable?.dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* make the api call to fetch the campaigns
|
||||
*/
|
||||
@SuppressLint("CheckResult")
|
||||
fun getCampaigns() {
|
||||
if (view != null && okHttpJsonApiClient != null) {
|
||||
//If we already have a campaign, lets not make another call
|
||||
if (campaign != null) {
|
||||
view!!.showCampaigns(campaign)
|
||||
return
|
||||
}
|
||||
|
||||
okHttpJsonApiClient.getCampaigns()
|
||||
.observeOn(mainThreadScheduler)
|
||||
.subscribeOn(ioScheduler)
|
||||
.doOnSubscribe { disposable = it }
|
||||
.subscribe({ campaignResponseDTO ->
|
||||
val campaigns = campaignResponseDTO?.campaigns?.toMutableList()
|
||||
if (campaigns.isNullOrEmpty()) {
|
||||
Timber.e("The campaigns list is empty")
|
||||
view!!.showCampaigns(null)
|
||||
} else {
|
||||
sortCampaignsByStartDate(campaigns)
|
||||
campaign = findActiveCampaign(campaigns)
|
||||
view!!.showCampaigns(campaign)
|
||||
}
|
||||
}, {
|
||||
Timber.e(it, "could not fetch campaigns")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortCampaignsByStartDate(campaigns: MutableList<Campaign>) {
|
||||
val dateFormat: SimpleDateFormat = getIso8601DateFormatShort()
|
||||
campaigns.sortWith(Comparator { campaign: Campaign, other: Campaign ->
|
||||
val date1: Date?
|
||||
val date2: Date?
|
||||
try {
|
||||
date1 = campaign.startDate?.let { dateFormat.parse(it) }
|
||||
date2 = other.startDate?.let { dateFormat.parse(it) }
|
||||
} catch (e: ParseException) {
|
||||
Timber.e(e)
|
||||
return@Comparator -1
|
||||
}
|
||||
if (date1 != null && date2 != null) date1.compareTo(date2) else -1
|
||||
})
|
||||
}
|
||||
|
||||
private fun findActiveCampaign(campaigns: List<Campaign>) : Campaign? {
|
||||
val dateFormat: SimpleDateFormat = getIso8601DateFormatShort()
|
||||
val currentDate = Date()
|
||||
return try {
|
||||
campaigns.firstOrNull {
|
||||
val campaignStartDate = it.startDate?.let { s -> dateFormat.parse(s) }
|
||||
val campaignEndDate = it.endDate?.let { s -> dateFormat.parse(s) }
|
||||
campaignStartDate != null && campaignEndDate != null &&
|
||||
campaignEndDate >= currentDate && campaignStartDate <= currentDate
|
||||
}
|
||||
} catch (e: ParseException) {
|
||||
Timber.e(e, "could not find active campaign")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package fr.free.nrw.commons.campaigns;
|
||||
|
||||
import fr.free.nrw.commons.MvpView;
|
||||
|
||||
/**
|
||||
* Interface which defines the view contracts of the campaign view
|
||||
*/
|
||||
public interface ICampaignsView extends MvpView {
|
||||
void showCampaigns(Campaign campaign);
|
||||
}
|
||||
|
|
@ -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?)
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package fr.free.nrw.commons.campaigns.models
|
||||
|
||||
/**
|
||||
* A data class to hold a campaign
|
||||
*/
|
||||
data class Campaign(
|
||||
var title: String? = null,
|
||||
var description: String? = null,
|
||||
var startDate: String? = null,
|
||||
var endDate: String? = null,
|
||||
var link: String? = null,
|
||||
var isWLMCampaign: Boolean = false,
|
||||
)
|
||||
|
|
@ -1,184 +1,300 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
import android.text.TextUtils
|
||||
import fr.free.nrw.commons.Media
|
||||
import fr.free.nrw.commons.upload.GpsCategoryModel
|
||||
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
|
||||
import fr.free.nrw.commons.utils.StringSortingUtils
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.functions.Function4
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The model class for categories in upload
|
||||
*/
|
||||
class CategoriesModel @Inject constructor(
|
||||
private val categoryClient: CategoryClient,
|
||||
private val categoryDao: CategoryDao,
|
||||
private val gpsCategoryModel: GpsCategoryModel
|
||||
) {
|
||||
private val selectedCategories: MutableList<CategoryItem> = mutableListOf()
|
||||
class CategoriesModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val categoryClient: CategoryClient,
|
||||
private val categoryDao: CategoryDao,
|
||||
private val gpsCategoryModel: GpsCategoryModel,
|
||||
) {
|
||||
private val selectedCategories: MutableList<CategoryItem> = mutableListOf()
|
||||
|
||||
/**
|
||||
* Returns if the item contains an year
|
||||
* @param item
|
||||
* @return
|
||||
*/
|
||||
fun containsYear(item: String): Boolean {
|
||||
//Check for current and previous year to exclude these categories from removal
|
||||
val now = Calendar.getInstance()
|
||||
val year = now[Calendar.YEAR]
|
||||
val yearInString = year.toString()
|
||||
val prevYear = year - 1
|
||||
val prevYearInString = prevYear.toString()
|
||||
Timber.d("Previous year: %s", prevYearInString)
|
||||
/**
|
||||
* Existing categories which are selected
|
||||
*/
|
||||
private var selectedExistingCategories: MutableList<String> = mutableListOf()
|
||||
|
||||
//Check if item contains a 4-digit word anywhere within the string (.* is wildcard)
|
||||
//And that item does not equal the current year or previous year
|
||||
//And if it is an irrelevant category such as Media_needing_categories_as_of_16_June_2017(Issue #750)
|
||||
//Check if the year in the form of XX(X)0s is relevant, i.e. in the 2000s or 2010s as stated in Issue #1029
|
||||
return item.matches(".*(19|20)\\d{2}.*".toRegex())
|
||||
&& !item.contains(yearInString)
|
||||
&& !item.contains(prevYearInString)
|
||||
&& (item.matches("(.*)needing(.*)".toRegex())
|
||||
|| item.matches("(.*)taken on(.*)".toRegex()))
|
||||
|| (item.matches(".*0s.*".toRegex())
|
||||
&& !item.matches(".*(200|201|202)0s.*".toRegex()))
|
||||
}
|
||||
/**
|
||||
* Returns true if an item is considered to be a spammy category which should be ignored
|
||||
*
|
||||
* @param item a category item that needs to be validated to know if it is spammy or not
|
||||
* @return
|
||||
*/
|
||||
fun isSpammyCategory(item: String): Boolean {
|
||||
val now = Calendar.getInstance()
|
||||
val curYear = now[Calendar.YEAR]
|
||||
val curYearInString = curYear.toString()
|
||||
val prevYear = curYear - 1
|
||||
val prevYearInString = prevYear.toString()
|
||||
Timber.d("Previous year: %s", prevYearInString)
|
||||
|
||||
/**
|
||||
* Updates category count in category dao
|
||||
* @param item
|
||||
*/
|
||||
fun updateCategoryCount(item: CategoryItem) {
|
||||
var category = categoryDao.find(item.name)
|
||||
val mentionsDecade = item.matches(".*0s.*".toRegex())
|
||||
val recentDecade = item.matches(".*20[0-2]0s.*".toRegex())
|
||||
val spammyCategory = item.matches("(.*)needing(.*)".toRegex())
|
||||
|| item.matches("(.*)taken on(.*)".toRegex())
|
||||
|
||||
// Newly used category...
|
||||
if (category == null) {
|
||||
category = Category(null, item.name, item.description, item.thumbnail, Date(), 0)
|
||||
}
|
||||
category.incTimesUsed()
|
||||
categoryDao.save(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regional category search
|
||||
* @param term
|
||||
* @param imageTitleList
|
||||
* @return
|
||||
*/
|
||||
fun searchAll(
|
||||
term: String,
|
||||
imageTitleList: List<String>,
|
||||
selectedDepictions: List<DepictedItem>
|
||||
): Observable<List<CategoryItem>> {
|
||||
return suggestionsOrSearch(term, imageTitleList, selectedDepictions)
|
||||
.map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } }
|
||||
}
|
||||
|
||||
private fun suggestionsOrSearch(
|
||||
term: String,
|
||||
imageTitleList: List<String>,
|
||||
selectedDepictions: List<DepictedItem>
|
||||
): Observable<List<CategoryItem>> {
|
||||
return if (TextUtils.isEmpty(term))
|
||||
Observable.combineLatest(
|
||||
categoriesFromDepiction(selectedDepictions),
|
||||
gpsCategoryModel.categoriesFromLocation,
|
||||
titleCategories(imageTitleList),
|
||||
Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)),
|
||||
Function4(::combine)
|
||||
)
|
||||
else
|
||||
categoryClient.searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT)
|
||||
.map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) }
|
||||
.toObservable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches details of every category associated with selected depictions, converts them into
|
||||
* CategoryItem and returns them in a list.
|
||||
*
|
||||
* @param selectedDepictions selected DepictItems
|
||||
* @return List of CategoryItem associated with selected depictions
|
||||
*/
|
||||
private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>):
|
||||
Observable<MutableList<CategoryItem>>? {
|
||||
return Observable.fromIterable(
|
||||
selectedDepictions.map { it.commonsCategories }.flatten())
|
||||
.map { categoryItem ->
|
||||
categoryClient.getCategoriesByName(categoryItem.name,
|
||||
categoryItem.name, SEARCH_CATS_LIMIT).map {
|
||||
|
||||
CategoryItem(it[0].name, it[0].description,
|
||||
it[0].thumbnail, it[0].isSelected)
|
||||
|
||||
}.blockingGet()
|
||||
}.toList().toObservable()
|
||||
}
|
||||
|
||||
private fun combine(
|
||||
depictionCategories: List<CategoryItem>,
|
||||
locationCategories: List<CategoryItem>,
|
||||
titles: List<CategoryItem>,
|
||||
recents: List<CategoryItem>
|
||||
) = depictionCategories + locationCategories + titles + recents
|
||||
|
||||
|
||||
/**
|
||||
* Returns title based categories
|
||||
* @param titleList
|
||||
* @return
|
||||
*/
|
||||
private fun titleCategories(titleList: List<String>) =
|
||||
if (titleList.isNotEmpty())
|
||||
Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults ->
|
||||
searchResults.map { it as List<CategoryItem> }.flatten()
|
||||
// always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750)
|
||||
if (spammyCategory) {
|
||||
return true
|
||||
}
|
||||
else
|
||||
Observable.just(emptyList())
|
||||
|
||||
/**
|
||||
* Return category for single title
|
||||
* @param title
|
||||
* @return
|
||||
*/
|
||||
private fun getTitleCategories(title: String): Observable<List<CategoryItem>> {
|
||||
return categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable()
|
||||
}
|
||||
if (mentionsDecade) {
|
||||
// Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029
|
||||
// Example: "2020s" is OK, but "1920s" is not (and should be skipped)
|
||||
return !recentDecade
|
||||
}
|
||||
else {
|
||||
// If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item
|
||||
// contains a phrase of the form "in 4-digit year"
|
||||
// anywhere within the string (.* is wildcard) (Issue #47)
|
||||
// And that item does not equal the current year or previous year
|
||||
return item.matches(".*in (19|20)\\d{2}.*".toRegex())
|
||||
&& !item.contains(curYearInString)
|
||||
&& !item.contains(prevYearInString)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates category count in category dao
|
||||
* @param item
|
||||
*/
|
||||
fun updateCategoryCount(item: CategoryItem) {
|
||||
var category = categoryDao.find(item.name)
|
||||
|
||||
/**
|
||||
* Handles category item selection
|
||||
* @param item
|
||||
*/
|
||||
fun onCategoryItemClicked(item: CategoryItem) {
|
||||
if (item.isSelected) {
|
||||
selectedCategories.add(item)
|
||||
updateCategoryCount(item)
|
||||
} else {
|
||||
selectedCategories.remove(item)
|
||||
// Newly used category...
|
||||
if (category == null) {
|
||||
category = Category(
|
||||
null, item.name,
|
||||
item.description,
|
||||
item.thumbnail,
|
||||
Date(),
|
||||
0
|
||||
)
|
||||
}
|
||||
category.incTimesUsed()
|
||||
categoryDao.save(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regional category search
|
||||
* @param term
|
||||
* @param imageTitleList
|
||||
* @return
|
||||
*/
|
||||
fun searchAll(
|
||||
term: String,
|
||||
imageTitleList: List<String>,
|
||||
selectedDepictions: List<DepictedItem>,
|
||||
): Observable<List<CategoryItem>> =
|
||||
suggestionsOrSearch(term, imageTitleList, selectedDepictions)
|
||||
.map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } }
|
||||
|
||||
private fun suggestionsOrSearch(
|
||||
term: String,
|
||||
imageTitleList: List<String>,
|
||||
selectedDepictions: List<DepictedItem>,
|
||||
): Observable<List<CategoryItem>> =
|
||||
if (TextUtils.isEmpty(term)) {
|
||||
Observable.combineLatest(
|
||||
categoriesFromDepiction(selectedDepictions),
|
||||
gpsCategoryModel.categoriesFromLocation,
|
||||
titleCategories(imageTitleList),
|
||||
Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)),
|
||||
Function4(::combine),
|
||||
)
|
||||
} else {
|
||||
categoryClient
|
||||
.searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT)
|
||||
.map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) }
|
||||
.toObservable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches details of every category associated with selected depictions, converts them into
|
||||
* CategoryItem and returns them in a list.
|
||||
*
|
||||
* @param selectedDepictions selected DepictItems
|
||||
* @return List of CategoryItem associated with selected depictions
|
||||
*/
|
||||
private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): Observable<MutableList<CategoryItem>>? =
|
||||
Observable
|
||||
.fromIterable(
|
||||
selectedDepictions.map { it.commonsCategories }.flatten(),
|
||||
).map { categoryItem ->
|
||||
categoryClient
|
||||
.getCategoriesByName(
|
||||
categoryItem.name,
|
||||
categoryItem.name,
|
||||
SEARCH_CATS_LIMIT,
|
||||
).map {
|
||||
CategoryItem(
|
||||
it[0].name,
|
||||
it[0].description,
|
||||
it[0].thumbnail,
|
||||
it[0].isSelected,
|
||||
)
|
||||
}.blockingGet()
|
||||
}.toList()
|
||||
.toObservable()
|
||||
|
||||
/**
|
||||
* Fetches details of every category by their name, converts them into
|
||||
* CategoryItem and returns them in a list.
|
||||
*
|
||||
* @param categoryNames selected Categories
|
||||
* @return List of CategoryItem
|
||||
*/
|
||||
fun getCategoriesByName(categoryNames: List<String>): Observable<MutableList<CategoryItem>>? =
|
||||
Observable
|
||||
.fromIterable(categoryNames)
|
||||
.map { categoryName ->
|
||||
buildCategories(categoryName)
|
||||
}.filter { categoryItem ->
|
||||
categoryItem.name != "Hidden"
|
||||
}.toList()
|
||||
.toObservable()
|
||||
|
||||
/**
|
||||
* Fetches the categories and converts them into CategoryItem
|
||||
*/
|
||||
fun buildCategories(categoryName: String): CategoryItem =
|
||||
categoryClient
|
||||
.getCategoriesByName(
|
||||
categoryName,
|
||||
categoryName,
|
||||
SEARCH_CATS_LIMIT,
|
||||
).map {
|
||||
if (it.isNotEmpty()) {
|
||||
CategoryItem(
|
||||
it[0].name,
|
||||
it[0].description,
|
||||
it[0].thumbnail,
|
||||
it[0].isSelected,
|
||||
)
|
||||
} else {
|
||||
CategoryItem(
|
||||
"Hidden",
|
||||
"Hidden",
|
||||
"hidden",
|
||||
false,
|
||||
)
|
||||
}
|
||||
}.blockingGet()
|
||||
|
||||
private fun combine(
|
||||
depictionCategories: List<CategoryItem>,
|
||||
locationCategories: List<CategoryItem>,
|
||||
titles: List<CategoryItem>,
|
||||
recents: List<CategoryItem>,
|
||||
) = depictionCategories + locationCategories + titles + recents
|
||||
|
||||
/**
|
||||
* Returns title based categories
|
||||
* @param titleList
|
||||
* @return
|
||||
*/
|
||||
private fun titleCategories(titleList: List<String>) =
|
||||
if (titleList.isNotEmpty()) {
|
||||
Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults ->
|
||||
searchResults.map { it as List<CategoryItem> }.flatten()
|
||||
}
|
||||
} else {
|
||||
Observable.just(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Return category for single title
|
||||
* @param title
|
||||
* @return
|
||||
*/
|
||||
private fun getTitleCategories(title: String): Observable<List<CategoryItem>> =
|
||||
categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable()
|
||||
|
||||
/**
|
||||
* Handles category item selection
|
||||
* @param item
|
||||
*/
|
||||
fun onCategoryItemClicked(
|
||||
item: CategoryItem,
|
||||
media: Media?,
|
||||
) {
|
||||
if (media == null) {
|
||||
if (item.isSelected) {
|
||||
selectedCategories.add(item)
|
||||
updateCategoryCount(item)
|
||||
} else {
|
||||
selectedCategories.remove(item)
|
||||
}
|
||||
} else {
|
||||
if (item.isSelected) {
|
||||
if (media.categories?.contains(item.name) == true) {
|
||||
selectedExistingCategories.add(item.name)
|
||||
} else {
|
||||
selectedCategories.add(item)
|
||||
updateCategoryCount(item)
|
||||
}
|
||||
} else {
|
||||
if (media.categories?.contains(item.name) == true) {
|
||||
selectedExistingCategories.remove(item.name)
|
||||
if (!media.categories?.contains(item.name)!!) {
|
||||
val categoriesList: MutableList<String> = ArrayList()
|
||||
categoriesList.add(item.name)
|
||||
categoriesList.addAll(media.categories!!)
|
||||
media.categories = categoriesList
|
||||
}
|
||||
} else {
|
||||
selectedCategories.remove(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Selected Categories
|
||||
* @return
|
||||
*/
|
||||
fun getSelectedCategories(): List<CategoryItem> = selectedCategories
|
||||
|
||||
/**
|
||||
* Cleanup the existing in memory cache's
|
||||
*/
|
||||
fun cleanUp() {
|
||||
selectedCategories.clear()
|
||||
selectedExistingCategories.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SEARCH_CATS_LIMIT = 25
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides selected existing categories
|
||||
*
|
||||
* @return selected existing categories
|
||||
*/
|
||||
fun getSelectedExistingCategories(): List<String> = selectedExistingCategories
|
||||
|
||||
/**
|
||||
* Initialize existing categories
|
||||
*
|
||||
* @param selectedExistingCategories existing categories
|
||||
*/
|
||||
fun setSelectedExistingCategories(selectedExistingCategories: MutableList<String>) {
|
||||
this.selectedExistingCategories = selectedExistingCategories
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Selected Categories
|
||||
* @return
|
||||
*/
|
||||
fun getSelectedCategories(): List<CategoryItem> {
|
||||
return selectedCategories
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup the existing in memory cache's
|
||||
*/
|
||||
fun cleanUp() {
|
||||
selectedCategories.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SEARCH_CATS_LIMIT = 25
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/fr/free/nrw/commons/category/Category.kt
Normal file
17
app/src/main/java/fr/free/nrw/commons/category/Category.kt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
import android.net.Uri
|
||||
import java.util.Date
|
||||
|
||||
data class Category(
|
||||
var contentUri: Uri? = null,
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val thumbnail: String? = null,
|
||||
val lastUsed: Date? = null,
|
||||
var timesUsed: Int = 0
|
||||
) {
|
||||
fun incTimesUsed() {
|
||||
timesUsed++
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
public interface CategoryClickedListener {
|
||||
void categoryClicked(CategoryItem item);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
interface CategoryClickedListener {
|
||||
fun categoryClicked(item: CategoryItem)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
|
||||
import io.reactivex.Single
|
||||
import org.wikipedia.dataclient.mwapi.MwQueryResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -15,109 +15,125 @@ const val CATEGORY_NEEDING_CATEGORIES = "needing categories"
|
|||
* Category Client to handle custom calls to Commons MediaWiki APIs
|
||||
*/
|
||||
@Singleton
|
||||
class CategoryClient @Inject constructor(private val categoryInterface: CategoryInterface) :
|
||||
ContinuationClient<MwQueryResponse, CategoryItem>() {
|
||||
class CategoryClient
|
||||
@Inject
|
||||
constructor(
|
||||
private val categoryInterface: CategoryInterface,
|
||||
) : ContinuationClient<MwQueryResponse, CategoryItem>() {
|
||||
/**
|
||||
* Searches for categories containing the specified string.
|
||||
*
|
||||
* @param filter The string to be searched
|
||||
* @param itemLimit How many results are returned
|
||||
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
|
||||
* @return
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun searchCategories(
|
||||
filter: String?,
|
||||
itemLimit: Int,
|
||||
offset: Int = 0,
|
||||
): Single<List<CategoryItem>> = responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset))
|
||||
|
||||
/**
|
||||
* Searches for categories containing the specified string.
|
||||
*
|
||||
* @param filter The string to be searched
|
||||
* @param itemLimit How many results are returned
|
||||
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
|
||||
* @return
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun searchCategories(filter: String?, itemLimit: Int, offset: Int = 0):
|
||||
Single<List<CategoryItem>> {
|
||||
return responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset))
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for categories starting with the specified string.
|
||||
*
|
||||
* @param prefix The prefix to be searched
|
||||
* @param itemLimit How many results are returned
|
||||
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
|
||||
* @return
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun searchCategoriesForPrefix(prefix: String?, itemLimit: Int, offset: Int = 0):
|
||||
Single<List<CategoryItem>> {
|
||||
return responseMapper(
|
||||
categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches categories starting and ending with a specified name.
|
||||
*
|
||||
* @param startingCategoryName Name of the category to start
|
||||
* @param endingCategoryName Name of the category to end
|
||||
* @param itemLimit How many categories to return
|
||||
* @param offset offset
|
||||
* @return MwQueryResponse
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun getCategoriesByName(startingCategoryName: String?, endingCategoryName: String?,
|
||||
itemLimit: Int, offset: Int = 0): Single<List<CategoryItem>> {
|
||||
return responseMapper(
|
||||
categoryInterface.getCategoriesByName(startingCategoryName, endingCategoryName,
|
||||
itemLimit, offset)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The method takes categoryName as input and returns a List of Subcategories
|
||||
* It uses the generator query API to get the subcategories in a category, 500 at a time.
|
||||
*
|
||||
* @param categoryName Category name as defined on commons
|
||||
* @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
|
||||
*/
|
||||
fun getSubCategoryList(categoryName: String): Single<List<CategoryItem>> {
|
||||
return continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) {
|
||||
categoryInterface.getSubCategoryList(
|
||||
categoryName, it
|
||||
/**
|
||||
* Searches for categories starting with the specified string.
|
||||
*
|
||||
* @param prefix The prefix to be searched
|
||||
* @param itemLimit How many results are returned
|
||||
* @param offset Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result
|
||||
* @return
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun searchCategoriesForPrefix(
|
||||
prefix: String?,
|
||||
itemLimit: Int,
|
||||
offset: Int = 0,
|
||||
): Single<List<CategoryItem>> =
|
||||
responseMapper(
|
||||
categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The method takes categoryName as input and returns a List of parent categories
|
||||
* It uses the generator query API to get the parent categories of a category, 500 at a time.
|
||||
*
|
||||
* @param categoryName Category name as defined on commons
|
||||
* @return
|
||||
*/
|
||||
fun getParentCategoryList(categoryName: String): Single<List<CategoryItem>> {
|
||||
return continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) {
|
||||
categoryInterface.getParentCategoryList(categoryName, it)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Fetches categories starting and ending with a specified name.
|
||||
*
|
||||
* @param startingCategoryName Name of the category to start
|
||||
* @param endingCategoryName Name of the category to end
|
||||
* @param itemLimit How many categories to return
|
||||
* @param offset offset
|
||||
* @return MwQueryResponse
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun getCategoriesByName(
|
||||
startingCategoryName: String?,
|
||||
endingCategoryName: String?,
|
||||
itemLimit: Int,
|
||||
offset: Int = 0,
|
||||
): Single<List<CategoryItem>> =
|
||||
responseMapper(
|
||||
categoryInterface.getCategoriesByName(
|
||||
startingCategoryName,
|
||||
endingCategoryName,
|
||||
itemLimit,
|
||||
offset,
|
||||
),
|
||||
)
|
||||
|
||||
fun resetSubCategoryContinuation(category: String) {
|
||||
resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category)
|
||||
}
|
||||
|
||||
fun resetParentCategoryContinuation(category: String) {
|
||||
resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category)
|
||||
}
|
||||
|
||||
override fun responseMapper(
|
||||
networkResult: Single<MwQueryResponse>,
|
||||
key: String?
|
||||
): Single<List<CategoryItem>> {
|
||||
return networkResult
|
||||
.map {
|
||||
handleContinuationResponse(it.continuation(), key)
|
||||
it.query()?.pages() ?: emptyList()
|
||||
/**
|
||||
* The method takes categoryName as input and returns a List of Subcategories
|
||||
* It uses the generator query API to get the subcategories in a category, 500 at a time.
|
||||
*
|
||||
* @param categoryName Category name as defined on commons
|
||||
* @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
|
||||
*/
|
||||
fun getSubCategoryList(categoryName: String): Single<List<CategoryItem>> =
|
||||
continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) {
|
||||
categoryInterface.getSubCategoryList(
|
||||
categoryName,
|
||||
it,
|
||||
)
|
||||
}
|
||||
.map {
|
||||
it.filter {
|
||||
page -> page.categoryInfo() == null || !page.categoryInfo().isHidden
|
||||
|
||||
/**
|
||||
* The method takes categoryName as input and returns a List of parent categories
|
||||
* It uses the generator query API to get the parent categories of a category, 500 at a time.
|
||||
*
|
||||
* @param categoryName Category name as defined on commons
|
||||
* @return
|
||||
*/
|
||||
fun getParentCategoryList(categoryName: String): Single<List<CategoryItem>> =
|
||||
continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) {
|
||||
categoryInterface.getParentCategoryList(categoryName, it)
|
||||
}
|
||||
|
||||
fun resetSubCategoryContinuation(category: String) {
|
||||
resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category)
|
||||
}
|
||||
|
||||
fun resetParentCategoryContinuation(category: String) {
|
||||
resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category)
|
||||
}
|
||||
|
||||
override fun responseMapper(
|
||||
networkResult: Single<MwQueryResponse>,
|
||||
key: String?,
|
||||
): Single<List<CategoryItem>> =
|
||||
networkResult
|
||||
.map {
|
||||
handleContinuationResponse(it.continuation(), key)
|
||||
it.query()?.pages() ?: emptyList()
|
||||
}.map {
|
||||
CategoryItem(it.title().replace(CATEGORY_PREFIX, ""),
|
||||
it.description().toString(), it.thumbUrl().toString(), false)
|
||||
it
|
||||
.filter { page ->
|
||||
// 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, ""),
|
||||
it.description().toString(),
|
||||
it.thumbUrl().toString(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.UriMatcher
|
||||
import android.content.UriMatcher.NO_MATCH
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import androidx.annotation.NonNull
|
||||
import fr.free.nrw.commons.BuildConfig
|
||||
import fr.free.nrw.commons.data.DBOpenHelper
|
||||
import fr.free.nrw.commons.di.CommonsDaggerContentProvider
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class CategoryContentProvider : CommonsDaggerContentProvider() {
|
||||
|
||||
private val uriMatcher = UriMatcher(NO_MATCH).apply {
|
||||
addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES)
|
||||
addURI(BuildConfig.CATEGORY_AUTHORITY, "${BASE_PATH}/#", CATEGORIES_ID)
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var dbOpenHelper: DBOpenHelper
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
|
||||
selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
|
||||
val queryBuilder = SQLiteQueryBuilder().apply {
|
||||
tables = TABLE_NAME
|
||||
}
|
||||
|
||||
val uriType = uriMatcher.match(uri)
|
||||
val db = dbOpenHelper.readableDatabase
|
||||
|
||||
val cursor: Cursor? = when (uriType) {
|
||||
CATEGORIES -> queryBuilder.query(
|
||||
db,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
null,
|
||||
null,
|
||||
sortOrder
|
||||
)
|
||||
CATEGORIES_ID -> queryBuilder.query(
|
||||
db,
|
||||
ALL_FIELDS,
|
||||
"_id = ?",
|
||||
arrayOf(uri.lastPathSegment),
|
||||
null,
|
||||
null,
|
||||
sortOrder
|
||||
)
|
||||
else -> throw IllegalArgumentException("Unknown URI $uri")
|
||||
}
|
||||
|
||||
cursor?.setNotificationUri(context?.contentResolver, uri)
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun insert(uri: Uri, contentValues: ContentValues?): Uri? {
|
||||
val uriType = uriMatcher.match(uri)
|
||||
val sqlDB = dbOpenHelper.writableDatabase
|
||||
val id: Long
|
||||
when (uriType) {
|
||||
CATEGORIES -> {
|
||||
id = sqlDB.insert(TABLE_NAME, null, contentValues)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown URI: $uri")
|
||||
}
|
||||
context?.contentResolver?.notifyChange(uri, null)
|
||||
return Uri.parse("${Companion.BASE_URI}/$id")
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
|
||||
// Not implemented
|
||||
return 0
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun bulkInsert(uri: Uri, values: Array<ContentValues>): Int {
|
||||
Timber.d("Hello, bulk insert! (CategoryContentProvider)")
|
||||
val uriType = uriMatcher.match(uri)
|
||||
val sqlDB = dbOpenHelper.writableDatabase
|
||||
sqlDB.beginTransaction()
|
||||
when (uriType) {
|
||||
CATEGORIES -> {
|
||||
for (value in values) {
|
||||
Timber.d("Inserting! %s", value)
|
||||
sqlDB.insert(TABLE_NAME, null, value)
|
||||
}
|
||||
sqlDB.setTransactionSuccessful()
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown URI: $uri")
|
||||
}
|
||||
sqlDB.endTransaction()
|
||||
context?.contentResolver?.notifyChange(uri, null)
|
||||
return values.size
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
override fun update(uri: Uri, contentValues: ContentValues?, selection: String?,
|
||||
selectionArgs: Array<String>?): Int {
|
||||
val uriType = uriMatcher.match(uri)
|
||||
val sqlDB = dbOpenHelper.writableDatabase
|
||||
val rowsUpdated: Int
|
||||
when (uriType) {
|
||||
CATEGORIES_ID -> {
|
||||
if (TextUtils.isEmpty(selection)) {
|
||||
val id = uri.lastPathSegment?.toInt()
|
||||
?: throw IllegalArgumentException("Invalid ID")
|
||||
rowsUpdated = sqlDB.update(TABLE_NAME,
|
||||
contentValues,
|
||||
"$COLUMN_ID = ?",
|
||||
arrayOf(id.toString()))
|
||||
} else {
|
||||
throw IllegalArgumentException(
|
||||
"Parameter `selection` should be empty when updating an ID")
|
||||
}
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown URI: $uri with type $uriType")
|
||||
}
|
||||
context?.contentResolver?.notifyChange(uri, null)
|
||||
return rowsUpdated
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_NAME = "categories"
|
||||
|
||||
const val COLUMN_ID = "_id"
|
||||
const val COLUMN_NAME = "name"
|
||||
const val COLUMN_DESCRIPTION = "description"
|
||||
const val COLUMN_THUMBNAIL = "thumbnail"
|
||||
const val COLUMN_LAST_USED = "last_used"
|
||||
const val COLUMN_TIMES_USED = "times_used"
|
||||
|
||||
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
|
||||
val ALL_FIELDS = arrayOf(
|
||||
COLUMN_ID,
|
||||
COLUMN_NAME,
|
||||
COLUMN_DESCRIPTION,
|
||||
COLUMN_THUMBNAIL,
|
||||
COLUMN_LAST_USED,
|
||||
COLUMN_TIMES_USED
|
||||
)
|
||||
|
||||
const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
|
||||
|
||||
const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
|
||||
"$COLUMN_ID INTEGER PRIMARY KEY," +
|
||||
"$COLUMN_NAME TEXT," +
|
||||
"$COLUMN_DESCRIPTION TEXT," +
|
||||
"$COLUMN_THUMBNAIL TEXT," +
|
||||
"$COLUMN_LAST_USED INTEGER," +
|
||||
"$COLUMN_TIMES_USED INTEGER" +
|
||||
");"
|
||||
|
||||
fun uriForId(id: Int): Uri {
|
||||
return Uri.parse("${BASE_URI}/$id")
|
||||
}
|
||||
|
||||
fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(CREATE_TABLE_STATEMENT)
|
||||
}
|
||||
|
||||
fun onDelete(db: SQLiteDatabase) {
|
||||
db.execSQL(DROP_TABLE_STATEMENT)
|
||||
onCreate(db)
|
||||
}
|
||||
|
||||
fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
|
||||
if (from == to) return
|
||||
if (from < 4) {
|
||||
// doesn't exist yet
|
||||
onUpdate(db, from + 1, to)
|
||||
} else if (from == 4) {
|
||||
// table added in version 5
|
||||
onCreate(db)
|
||||
onUpdate(db, from + 1, to)
|
||||
} else if (from == 5) {
|
||||
onUpdate(db, from + 1, to)
|
||||
} else if (from == 17) {
|
||||
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description TEXT;")
|
||||
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail TEXT;")
|
||||
onUpdate(db, from + 1, to)
|
||||
}
|
||||
}
|
||||
|
||||
// For URI matcher
|
||||
private const val CATEGORIES = 1
|
||||
private const val CATEGORIES_ID = 2
|
||||
private const val BASE_PATH = "categories"
|
||||
val BASE_URI: Uri = Uri.parse("content://${BuildConfig.CATEGORY_AUTHORITY}/${Companion.BASE_PATH}")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.os.RemoteException;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javax.inject.Provider;
|
||||
|
||||
public class CategoryDao {
|
||||
|
||||
private final Provider<ContentProviderClient> clientProvider;
|
||||
|
||||
@Inject
|
||||
public CategoryDao(@Named("category") Provider<ContentProviderClient> clientProvider) {
|
||||
this.clientProvider = clientProvider;
|
||||
}
|
||||
|
||||
public void save(Category category) {
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
if (category.getContentUri() == null) {
|
||||
category.setContentUri(db.insert(CategoryContentProvider.BASE_URI, toContentValues(category)));
|
||||
} else {
|
||||
db.update(category.getContentUri(), toContentValues(category), null, null);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find persisted category in database, based on its name.
|
||||
*
|
||||
* @param name Category's name
|
||||
* @return category from database, or null if not found
|
||||
*/
|
||||
@Nullable
|
||||
Category find(String name) {
|
||||
Cursor cursor = null;
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
cursor = db.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
Table.COLUMN_NAME + "=?",
|
||||
new String[]{name},
|
||||
null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return fromCursor(cursor);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
// This feels lazy, but to hell with checked exceptions. :)
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
db.release();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve recently-used categories, ordered by descending date.
|
||||
*
|
||||
* @return a list containing recent categories
|
||||
*/
|
||||
@NonNull
|
||||
List<CategoryItem> recentCategories(int limit) {
|
||||
List<CategoryItem> items = new ArrayList<>();
|
||||
Cursor cursor = null;
|
||||
ContentProviderClient db = clientProvider.get();
|
||||
try {
|
||||
cursor = db.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
null,
|
||||
new String[]{},
|
||||
Table.COLUMN_LAST_USED + " DESC");
|
||||
// fixme add a limit on the original query instead of falling out of the loop?
|
||||
while (cursor != null && cursor.moveToNext()
|
||||
&& cursor.getPosition() < limit) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
Normal file
194
app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.RemoteException
|
||||
|
||||
import java.util.ArrayList
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
import javax.inject.Provider
|
||||
|
||||
class CategoryDao @Inject constructor(
|
||||
@Named("category") private val clientProvider: Provider<ContentProviderClient>
|
||||
) {
|
||||
|
||||
fun save(category: Category) {
|
||||
val db = clientProvider.get()
|
||||
try {
|
||||
if (category.contentUri == null) {
|
||||
category.contentUri = db.insert(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
toContentValues(category)
|
||||
)
|
||||
} else {
|
||||
db.update(
|
||||
category.contentUri!!,
|
||||
toContentValues(category),
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
throw RuntimeException(e)
|
||||
} finally {
|
||||
db.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find persisted category in database, based on its name.
|
||||
*
|
||||
* @param name Category's name
|
||||
* @return category from database, or null if not found
|
||||
*/
|
||||
fun find(name: String): Category? {
|
||||
var cursor: Cursor? = null
|
||||
val db = clientProvider.get()
|
||||
try {
|
||||
cursor = db.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
ALL_FIELDS,
|
||||
"${COLUMN_NAME}=?",
|
||||
arrayOf(name),
|
||||
null
|
||||
)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return fromCursor(cursor)
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
throw RuntimeException(e)
|
||||
} finally {
|
||||
cursor?.close()
|
||||
db.release()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve recently-used categories, ordered by descending date.
|
||||
*
|
||||
* @return a list containing recent categories
|
||||
*/
|
||||
fun recentCategories(limit: Int): List<CategoryItem> {
|
||||
val items = ArrayList<CategoryItem>()
|
||||
var cursor: Cursor? = null
|
||||
val db = clientProvider.get()
|
||||
try {
|
||||
cursor = db.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
ALL_FIELDS,
|
||||
null,
|
||||
emptyArray(),
|
||||
"$COLUMN_LAST_USED DESC"
|
||||
)
|
||||
while (cursor != null && cursor.moveToNext() && cursor.position < limit) {
|
||||
val category = fromCursor(cursor)
|
||||
if (category.name != null) {
|
||||
items.add(
|
||||
CategoryItem(
|
||||
category.name,
|
||||
category.description,
|
||||
category.thumbnail,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
throw RuntimeException(e)
|
||||
} finally {
|
||||
cursor?.close()
|
||||
db.release()
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@SuppressLint("Range")
|
||||
fun fromCursor(cursor: Cursor): Category {
|
||||
// Hardcoding column positions!
|
||||
return Category(
|
||||
CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(COLUMN_ID))),
|
||||
cursor.getString(cursor.getColumnIndex(COLUMN_NAME)),
|
||||
cursor.getString(cursor.getColumnIndex(COLUMN_DESCRIPTION)),
|
||||
cursor.getString(cursor.getColumnIndex(COLUMN_THUMBNAIL)),
|
||||
Date(cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_USED))),
|
||||
cursor.getInt(cursor.getColumnIndex(COLUMN_TIMES_USED))
|
||||
)
|
||||
}
|
||||
|
||||
private fun toContentValues(category: Category): ContentValues {
|
||||
return ContentValues().apply {
|
||||
put(COLUMN_NAME, category.name)
|
||||
put(COLUMN_DESCRIPTION, category.description)
|
||||
put(COLUMN_THUMBNAIL, category.thumbnail)
|
||||
put(COLUMN_LAST_USED, category.lastUsed?.time)
|
||||
put(COLUMN_TIMES_USED, category.timesUsed)
|
||||
}
|
||||
}
|
||||
|
||||
companion object Table {
|
||||
const val TABLE_NAME = "categories"
|
||||
|
||||
const val COLUMN_ID = "_id"
|
||||
const val COLUMN_NAME = "name"
|
||||
const val COLUMN_DESCRIPTION = "description"
|
||||
const val COLUMN_THUMBNAIL = "thumbnail"
|
||||
const val COLUMN_LAST_USED = "last_used"
|
||||
const val COLUMN_TIMES_USED = "times_used"
|
||||
|
||||
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
|
||||
val ALL_FIELDS = arrayOf(
|
||||
COLUMN_ID,
|
||||
COLUMN_NAME,
|
||||
COLUMN_DESCRIPTION,
|
||||
COLUMN_THUMBNAIL,
|
||||
COLUMN_LAST_USED,
|
||||
COLUMN_TIMES_USED
|
||||
)
|
||||
|
||||
const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
|
||||
|
||||
const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
|
||||
"$COLUMN_ID INTEGER PRIMARY KEY," +
|
||||
"$COLUMN_NAME STRING," +
|
||||
"$COLUMN_DESCRIPTION STRING," +
|
||||
"$COLUMN_THUMBNAIL STRING," +
|
||||
"$COLUMN_LAST_USED INTEGER," +
|
||||
"$COLUMN_TIMES_USED INTEGER" +
|
||||
");"
|
||||
|
||||
@SuppressLint("SQLiteString")
|
||||
fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(CREATE_TABLE_STATEMENT)
|
||||
}
|
||||
|
||||
fun onDelete(db: SQLiteDatabase) {
|
||||
db.execSQL(DROP_TABLE_STATEMENT)
|
||||
onCreate(db)
|
||||
}
|
||||
|
||||
@SuppressLint("SQLiteString")
|
||||
fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
|
||||
if (from == to) return
|
||||
if (from < 4) {
|
||||
// doesn't exist yet
|
||||
onUpdate(db, from + 1, to)
|
||||
} else if (from == 4) {
|
||||
// table added in version 5
|
||||
onCreate(db)
|
||||
onUpdate(db, from + 1, to)
|
||||
} else if (from == 5) {
|
||||
onUpdate(db, from + 1, to)
|
||||
} else if (from == 17) {
|
||||
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description STRING;")
|
||||
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail STRING;")
|
||||
onUpdate(db, from + 1, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,243 +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 butterknife.BindView;
|
||||
import butterknife.ButterKnife;
|
||||
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.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 org.wikipedia.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;
|
||||
@BindView(R.id.mediaContainer) FrameLayout mediaContainer;
|
||||
@BindView(R.id.tab_layout) TabLayout tabLayout;
|
||||
@BindView(R.id.viewPager) ViewPager viewPager;
|
||||
@BindView(R.id.toolbar) Toolbar toolbar;
|
||||
ViewPagerAdapter viewPagerAdapter;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_category_details);
|
||||
ButterKnife.bind(this);
|
||||
supportFragmentManager = getSupportFragmentManager();
|
||||
viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager());
|
||||
viewPager.setAdapter(viewPagerAdapter);
|
||||
viewPager.setOffscreenPageLimit(2);
|
||||
tabLayout.setupWithViewPager(viewPager);
|
||||
setSupportActionBar(toolbar);
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
setTabs();
|
||||
setPageTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab,
|
||||
* Set the fragments according to the tab selected in the viewPager.
|
||||
*/
|
||||
private void setTabs() {
|
||||
List<Fragment> fragmentList = new ArrayList<>();
|
||||
List<String> titleList = new ArrayList<>();
|
||||
categoriesMediaFragment = new CategoriesMediaFragment();
|
||||
SubCategoriesFragment subCategoryListFragment = new SubCategoriesFragment();
|
||||
ParentCategoriesFragment parentCategoriesFragment = new ParentCategoriesFragment();
|
||||
categoryName = getIntent().getStringExtra("categoryName");
|
||||
if (getIntent() != null && categoryName != null) {
|
||||
Bundle arguments = new Bundle();
|
||||
arguments.putString("categoryName", categoryName);
|
||||
categoriesMediaFragment.setArguments(arguments);
|
||||
subCategoryListFragment.setArguments(arguments);
|
||||
parentCategoriesFragment.setArguments(arguments);
|
||||
}
|
||||
fragmentList.add(categoriesMediaFragment);
|
||||
titleList.add("MEDIA");
|
||||
fragmentList.add(subCategoryListFragment);
|
||||
titleList.add("SUBCATEGORIES");
|
||||
fragmentList.add(parentCategoriesFragment);
|
||||
titleList.add("PARENT CATEGORIES");
|
||||
viewPagerAdapter.setTabData(fragmentList, titleList);
|
||||
viewPagerAdapter.notifyDataSetChanged();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the passed categoryName from the intents and displays it as the page title
|
||||
*/
|
||||
private void setPageTitle() {
|
||||
if (getIntent() != null && getIntent().getStringExtra("categoryName") != null) {
|
||||
setTitle(getIntent().getStringExtra("categoryName"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called onClick of media inside category details (CategoryImageListFragment).
|
||||
*/
|
||||
@Override
|
||||
public void onMediaClicked(int position) {
|
||||
tabLayout.setVisibility(View.GONE);
|
||||
viewPager.setVisibility(View.GONE);
|
||||
mediaContainer.setVisibility(View.VISIBLE);
|
||||
if (mediaDetails == null || !mediaDetails.isVisible()) {
|
||||
// set isFeaturedImage true for featured images, to include author field on media detail
|
||||
mediaDetails = new MediaDetailPagerFragment(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){
|
||||
|
||||
// the back press is handled by the mediaDetails , no further action required.
|
||||
if(mediaDetails.backButtonClicked()){
|
||||
return;
|
||||
}
|
||||
|
||||
tabLayout.setVisibility(View.VISIBLE);
|
||||
viewPager.setVisibility(View.VISIBLE);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
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.activity.viewModels
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
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 kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@Inject
|
||||
lateinit var categoryViewModelFactory: CategoryDetailsViewModel.ViewModelFactory
|
||||
|
||||
private val viewModel: CategoryDetailsViewModel by viewModels<CategoryDetailsViewModel> { categoryViewModelFactory }
|
||||
|
||||
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()
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED){
|
||||
viewModel.bookmarkState.collect {
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab,
|
||||
* Set the fragments according to the tab selected in the viewPager.
|
||||
*/
|
||||
private fun setTabs() {
|
||||
val fragmentList = mutableListOf<Fragment>()
|
||||
val titleList = mutableListOf<String>()
|
||||
categoriesMediaFragment = CategoriesMediaFragment()
|
||||
val subCategoryListFragment = SubCategoriesFragment()
|
||||
val parentCategoriesFragment = ParentCategoriesFragment()
|
||||
categoryName = intent?.getStringExtra("categoryName")
|
||||
if (intent != null && categoryName != null) {
|
||||
val arguments = Bundle().apply {
|
||||
putString("categoryName", categoryName)
|
||||
}
|
||||
categoriesMediaFragment.arguments = arguments
|
||||
subCategoryListFragment.arguments = arguments
|
||||
parentCategoriesFragment.arguments = arguments
|
||||
|
||||
viewModel.onCheckIfBookmarked(categoryName!!)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
R.id.menu_bookmark_current_category -> {
|
||||
categoryName?.let {
|
||||
viewModel.onBookmarkClick(categoryName = it)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
|
||||
menu?.run {
|
||||
val bookmarkMenuItem = findItem(R.id.menu_bookmark_current_category)
|
||||
if (bookmarkMenuItem != null) {
|
||||
val icon = if(viewModel.bookmarkState.value){
|
||||
R.drawable.menu_ic_round_star_filled_24px
|
||||
} else {
|
||||
R.drawable.menu_ic_round_star_border_24px
|
||||
}
|
||||
|
||||
bookmarkMenuItem.setIcon(icon)
|
||||
}
|
||||
}
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao
|
||||
import fr.free.nrw.commons.bookmarks.category.BookmarksCategoryModal
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModal for [CategoryDetailsActivity]
|
||||
*/
|
||||
class CategoryDetailsViewModel(
|
||||
private val bookmarkCategoriesDao: BookmarkCategoriesDao
|
||||
) : ViewModel() {
|
||||
|
||||
private val _bookmarkState = MutableStateFlow(false)
|
||||
val bookmarkState = _bookmarkState.asStateFlow()
|
||||
|
||||
|
||||
/**
|
||||
* Used to check if bookmark exists for the given category in DB
|
||||
* based on that bookmark state is updated
|
||||
* @param categoryName
|
||||
*/
|
||||
fun onCheckIfBookmarked(categoryName: String) {
|
||||
viewModelScope.launch {
|
||||
val isBookmarked = bookmarkCategoriesDao.doesExist(categoryName)
|
||||
_bookmarkState.update {
|
||||
isBookmarked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles event when bookmark button is clicked from view
|
||||
* based on that category is bookmarked or removed in/from in the DB
|
||||
* and bookmark state is update as well
|
||||
* @param categoryName
|
||||
*/
|
||||
fun onBookmarkClick(categoryName: String) {
|
||||
if (_bookmarkState.value) {
|
||||
deleteBookmark(categoryName)
|
||||
_bookmarkState.update {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
addBookmark(categoryName)
|
||||
_bookmarkState.update {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add bookmark into DB
|
||||
*
|
||||
* @param categoryName
|
||||
*/
|
||||
private fun addBookmark(categoryName: String) {
|
||||
viewModelScope.launch {
|
||||
val categoryItem = BookmarksCategoryModal(
|
||||
categoryName = categoryName
|
||||
)
|
||||
|
||||
bookmarkCategoriesDao.insert(categoryItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete bookmark from DB
|
||||
*
|
||||
* @param categoryName
|
||||
*/
|
||||
private fun deleteBookmark(categoryName: String) {
|
||||
viewModelScope.launch {
|
||||
bookmarkCategoriesDao.delete(
|
||||
BookmarksCategoryModal(
|
||||
categoryName = categoryName
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View model factory to create [CategoryDetailsViewModel]
|
||||
*
|
||||
* @property bookmarkCategoriesDao
|
||||
* @constructor Create empty View model factory
|
||||
*/
|
||||
class ViewModelFactory @Inject constructor(
|
||||
private val bookmarkCategoriesDao: BookmarkCategoriesDao
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
if (modelClass.isAssignableFrom(CategoryDetailsViewModel::class.java)) {
|
||||
CategoryDetailsViewModel(bookmarkCategoriesDao) as T
|
||||
} else {
|
||||
throw IllegalArgumentException("Unknown class name")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +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 android.util.Log;
|
||||
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;
|
||||
private Callback callback;
|
||||
|
||||
@Inject
|
||||
public CategoryEditHelper(NotificationHelper notificationHelper,
|
||||
@Named("commons-page-edit") PageEditClient pageEditClient,
|
||||
ViewUtilWrapper viewUtil,
|
||||
@Named("username") String username) {
|
||||
this.notificationHelper = notificationHelper;
|
||||
this.pageEditClient = pageEditClient;
|
||||
this.viewUtil = viewUtil;
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public interface to edit categories
|
||||
* @param context
|
||||
* @param media
|
||||
* @param categories
|
||||
* @return
|
||||
*/
|
||||
public Single<Boolean> makeCategoryEdit(Context context, Media media, List<String> categories, Callback callback) {
|
||||
viewUtil.showShortToast(context, context.getString(R.string.category_edit_helper_make_edit_toast));
|
||||
return addCategory(media, categories)
|
||||
.flatMapSingle(result -> Single.just(showCategoryEditNotification(context, media, result)))
|
||||
.firstOrError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends new categories
|
||||
* @param media
|
||||
* @param categories to be added
|
||||
* @return
|
||||
*/
|
||||
private Observable<Boolean> addCategory(Media media, List<String> categories) {
|
||||
Timber.d("thread is category adding %s", Thread.currentThread().getName());
|
||||
String summary = "Adding categories";
|
||||
|
||||
StringBuilder buffer = new StringBuilder();
|
||||
|
||||
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}}");
|
||||
}
|
||||
String appendText = buffer.toString();
|
||||
return pageEditClient.appendEdit(media.getFilename(), appendText + "\n", summary);
|
||||
}
|
||||
|
||||
private boolean showCategoryEditNotification(Context context, Media media, boolean result) {
|
||||
String message;
|
||||
String title = context.getString(R.string.category_edit_helper_show_edit_title);
|
||||
|
||||
if (result) {
|
||||
title += ": " + context.getString(R.string.category_edit_helper_show_edit_title_success);
|
||||
StringBuilder categoriesInMessage = new StringBuilder();
|
||||
List<String> mediaCategoryList = media.getCategories();
|
||||
for (String category : mediaCategoryList) {
|
||||
categoriesInMessage.append(category);
|
||||
if (category.equals(mediaCategoryList.get(mediaCategoryList.size()-1))) {
|
||||
continue;
|
||||
}
|
||||
categoriesInMessage.append(",");
|
||||
}
|
||||
|
||||
message = context.getResources().getQuantityString(R.plurals.category_edit_helper_show_edit_message_if, mediaCategoryList.size(), categoriesInMessage.toString());
|
||||
} else {
|
||||
title += ": " + context.getString(R.string.category_edit_helper_show_edit_title);
|
||||
message = context.getString(R.string.category_edit_helper_edit_message_else) ;
|
||||
}
|
||||
|
||||
String urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.getFilename();
|
||||
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile));
|
||||
notificationHelper.showNotification(context, title, message, NOTIFICATION_EDIT_CATEGORY, browserIntent);
|
||||
return result;
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
boolean updateCategoryDisplay(List<String> categories);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import fr.free.nrw.commons.BuildConfig
|
||||
import fr.free.nrw.commons.Media
|
||||
import fr.free.nrw.commons.R
|
||||
import fr.free.nrw.commons.actions.PageEditClient
|
||||
import fr.free.nrw.commons.notification.NotificationHelper
|
||||
import fr.free.nrw.commons.utils.ViewUtilWrapper
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
import timber.log.Timber
|
||||
|
||||
|
||||
class CategoryEditHelper @Inject constructor(
|
||||
private val notificationHelper: NotificationHelper,
|
||||
@Named("commons-page-edit") val pageEditClient: PageEditClient,
|
||||
private val viewUtil: ViewUtilWrapper,
|
||||
@Named("username") private val username: String
|
||||
) {
|
||||
|
||||
/**
|
||||
* Public interface to edit categories
|
||||
* @param context
|
||||
* @param media
|
||||
* @param categories
|
||||
* @return
|
||||
*/
|
||||
fun makeCategoryEdit(
|
||||
context: Context,
|
||||
media: Media,
|
||||
categories: List<String>,
|
||||
wikiText: String
|
||||
): Single<Boolean> {
|
||||
viewUtil.showShortToast(
|
||||
context,
|
||||
context.getString(R.string.category_edit_helper_make_edit_toast)
|
||||
)
|
||||
return addCategory(media, categories, wikiText)
|
||||
.flatMapSingle { result ->
|
||||
Single.just(showCategoryEditNotification(context, media, result))
|
||||
}
|
||||
.firstOrError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the WikiText with new categories and post it on server
|
||||
*
|
||||
* @param media
|
||||
* @param categories to be added
|
||||
* @return
|
||||
*/
|
||||
private fun addCategory(
|
||||
media: Media,
|
||||
categories: List<String>?,
|
||||
wikiText: String
|
||||
): Observable<Boolean> {
|
||||
Timber.d("thread is category adding %s", Thread.currentThread().name)
|
||||
val summary = "Adding categories"
|
||||
val buffer = StringBuilder()
|
||||
|
||||
// If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category"
|
||||
val wikiTextWithoutCategory: String = when {
|
||||
wikiText.contains("Uncategorized") -> wikiText.substring(0, wikiText.indexOf("Uncategorized"))
|
||||
wikiText.contains("[[Category") -> wikiText.substring(0, wikiText.indexOf("[[Category"))
|
||||
else -> ""
|
||||
}
|
||||
|
||||
if (!categories.isNullOrEmpty()) {
|
||||
// If the categories list is empty, when reading the categories of a picture,
|
||||
// the code will add "None selected" to categories list in order to see in picture's categories with "None selected".
|
||||
// So that after selecting some category, "None selected" should be removed from list
|
||||
for (category in categories) {
|
||||
if (category != "None selected" || !wikiText.contains("Uncategorized")) {
|
||||
buffer.append("[[Category:").append(category).append("]]\n")
|
||||
}
|
||||
}
|
||||
categories.dropWhile {
|
||||
it == "None selected"
|
||||
}
|
||||
} else {
|
||||
buffer.append("{{subst:unc}}")
|
||||
}
|
||||
|
||||
val appendText = wikiTextWithoutCategory + buffer
|
||||
return pageEditClient.edit(media.filename!!, "$appendText\n", summary)
|
||||
}
|
||||
|
||||
private fun showCategoryEditNotification(
|
||||
context: Context,
|
||||
media: Media,
|
||||
result: Boolean
|
||||
): Boolean {
|
||||
val title: String
|
||||
val message: String
|
||||
|
||||
if (result) {
|
||||
title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
|
||||
context.getString(R.string.category_edit_helper_show_edit_title_success)
|
||||
|
||||
val categoriesInMessage = StringBuilder()
|
||||
val mediaCategoryList = media.categories
|
||||
for ((index, category) in mediaCategoryList?.withIndex()!!) {
|
||||
categoriesInMessage.append(category)
|
||||
if (index != mediaCategoryList.size - 1) {
|
||||
categoriesInMessage.append(",")
|
||||
}
|
||||
}
|
||||
|
||||
message = context.resources.getQuantityString(
|
||||
R.plurals.category_edit_helper_show_edit_message_if,
|
||||
mediaCategoryList.size,
|
||||
categoriesInMessage.toString()
|
||||
)
|
||||
} else {
|
||||
title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
|
||||
context.getString(R.string.category_edit_helper_show_edit_title)
|
||||
message = context.getString(R.string.category_edit_helper_edit_message_else)
|
||||
}
|
||||
|
||||
val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}"
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile))
|
||||
notificationHelper.showNotification(
|
||||
context,
|
||||
title,
|
||||
message,
|
||||
NOTIFICATION_EDIT_CATEGORY,
|
||||
browserIntent
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun updateCategoryDisplay(categories: List<String>?): Boolean
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_EDIT_CATEGORY = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.CompoundButton;
|
||||
import android.widget.CompoundButton.OnCheckedChangeListener;
|
||||
import android.widget.Filter;
|
||||
import android.widget.Filterable;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.category.CategoryEditSearchRecyclerViewAdapter.RecyclerViewHolder;
|
||||
import fr.free.nrw.commons.nearby.Label;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class CategoryEditSearchRecyclerViewAdapter
|
||||
extends RecyclerView.Adapter<RecyclerViewHolder>
|
||||
implements Filterable {
|
||||
|
||||
private List<String> displayedCategories;
|
||||
private List<String> categories = new ArrayList<>();
|
||||
private List<String> newCategories = new ArrayList<>();
|
||||
private final LayoutInflater inflater;
|
||||
private CategoryClient categoryClient;
|
||||
private Context context;
|
||||
|
||||
private Callback callback;
|
||||
|
||||
public CategoryEditSearchRecyclerViewAdapter(Context context, ArrayList<Label> labels,
|
||||
RecyclerView categoryRecyclerView, CategoryClient categoryClient, Callback callback) {
|
||||
this.context = context;
|
||||
inflater = LayoutInflater.from(context);
|
||||
this.categoryClient = categoryClient;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public void addToCategories(List<String> categories) {
|
||||
for(String category : categories) {
|
||||
if (!this.categories.contains(category)) {
|
||||
this.categories.add(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void removeFromNewCategories(String categoryToBeRemoved) {
|
||||
if (newCategories.contains(categoryToBeRemoved)) {
|
||||
newCategories.remove(categoryToBeRemoved);
|
||||
}
|
||||
}
|
||||
|
||||
public void addToNewCategories(String addedCategory) {
|
||||
if (!newCategories.contains(addedCategory)) {
|
||||
newCategories.add(addedCategory);
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getCategories() {
|
||||
return categories;
|
||||
}
|
||||
|
||||
public List<String> getNewCategories() {
|
||||
return newCategories;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Filter getFilter() {
|
||||
return new Filter() {
|
||||
|
||||
@Override
|
||||
protected FilterResults performFiltering(CharSequence constraint) {
|
||||
FilterResults results = new FilterResults();
|
||||
List<CategoryItem> resultCategories = categoryClient
|
||||
.searchCategories(constraint.toString(), 10).blockingGet();
|
||||
final List<String> namesOfCommonsCategories = new ArrayList<>();
|
||||
for (final CategoryItem category :
|
||||
resultCategories) {
|
||||
namesOfCommonsCategories.add(category.getName());
|
||||
}
|
||||
results.values = namesOfCommonsCategories;
|
||||
results.count = resultCategories.size();
|
||||
return results;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void publishResults(CharSequence constraint, FilterResults results) {
|
||||
List<String> resultList = (List<String>)results.values;
|
||||
// Do not re-add already added categories
|
||||
for (String category : categories) {
|
||||
if (resultList.contains(category)) {
|
||||
resultList.remove(category);
|
||||
}
|
||||
}
|
||||
|
||||
displayedCategories = resultList;
|
||||
notifyDataSetChanged();
|
||||
if (displayedCategories.size()==0) {
|
||||
callback.noResultsFound();
|
||||
} else {
|
||||
callback.someResultsFound();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public class RecyclerViewHolder extends RecyclerView.ViewHolder {
|
||||
public CheckBox categoryCheckBox;
|
||||
public TextView categoryTextView;
|
||||
|
||||
public RecyclerViewHolder(View view) {
|
||||
super(view);
|
||||
categoryCheckBox = view.findViewById(R.id.category_checkbox);
|
||||
categoryTextView = view.findViewById(R.id.category_text);
|
||||
categoryCheckBox.setOnCheckedChangeListener(new OnCheckedChangeListener() {
|
||||
@Override
|
||||
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
|
||||
if (isChecked) {
|
||||
addToNewCategories(categoryTextView.getText().toString());
|
||||
} else {
|
||||
removeFromNewCategories(categoryTextView.getText().toString());
|
||||
}
|
||||
List<String> allCategories = new ArrayList<>();
|
||||
allCategories.addAll(categories);
|
||||
allCategories.addAll(newCategories);
|
||||
callback.updateSelectedCategoriesTextView(allCategories);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public RecyclerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View itemView = inflater.inflate(R.layout.layout_edit_category_item , parent, false);
|
||||
return new RecyclerViewHolder(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerViewHolder holder, int position) {
|
||||
holder.categoryTextView.setText(displayedCategories.get(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return displayedCategories.get(position).hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (displayedCategories == null) ? 0 : displayedCategories.size();
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
void updateSelectedCategoriesTextView(List<String> selectedCategories);
|
||||
void noResultsFound();
|
||||
void someResultsFound();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
interface CategoryImagesCallback {
|
||||
fun viewPagerNotifyDataSetChanged()
|
||||
|
||||
fun onMediaClicked(position: Int)
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import io.reactivex.Single;
|
||||
import java.util.Map;
|
||||
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Query;
|
||||
import retrofit2.http.QueryMap;
|
||||
|
||||
/**
|
||||
* Interface for interacting with Commons category related APIs
|
||||
*/
|
||||
public interface CategoryInterface {
|
||||
|
||||
/**
|
||||
* Searches for categories with the specified name.
|
||||
*
|
||||
* @param filter The string to be searched
|
||||
* @param itemLimit How many results are returned
|
||||
* @return
|
||||
*/
|
||||
@GET("w/api.php?action=query&format=json&formatversion=2"
|
||||
+ "&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70"
|
||||
+ "&gsrnamespace=14")
|
||||
Single<MwQueryResponse> searchCategories(@Query("gsrsearch") String filter,
|
||||
@Query("gsrlimit") int itemLimit,
|
||||
@Query("gsroffset") int offset);
|
||||
|
||||
/**
|
||||
* Searches for categories starting with the specified prefix.
|
||||
*
|
||||
* @param prefix The string to be searched
|
||||
* @param itemLimit How many results are returned
|
||||
* @return
|
||||
*/
|
||||
@GET("w/api.php?action=query&format=json&formatversion=2"
|
||||
+ "&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail"
|
||||
+ "&pithumbsize=70")
|
||||
Single<MwQueryResponse> searchCategoriesForPrefix(@Query("gacprefix") String prefix,
|
||||
@Query("gaclimit") int itemLimit,
|
||||
@Query("gacoffset") int offset);
|
||||
|
||||
/**
|
||||
* Fetches categories starting and ending with a specified name.
|
||||
*
|
||||
* @param startingCategory Name of the category to start
|
||||
* @param endingCategory Name of the category to end
|
||||
* @param itemLimit How many categories to return
|
||||
* @param offset offset
|
||||
* @return MwQueryResponse
|
||||
*/
|
||||
@GET("w/api.php?action=query&format=json&formatversion=2"
|
||||
+ "&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail"
|
||||
+ "&pithumbsize=70")
|
||||
Single<MwQueryResponse> getCategoriesByName(@Query("gacfrom") String startingCategory,
|
||||
@Query("gacto") String endingCategory,
|
||||
@Query("gaclimit") int itemLimit,
|
||||
@Query("gacoffset") int offset);
|
||||
|
||||
@GET("w/api.php?action=query&format=json&formatversion=2"
|
||||
+ "&generator=categorymembers&gcmtype=subcat"
|
||||
+ "&prop=info&gcmlimit=50")
|
||||
Single<MwQueryResponse> getSubCategoryList(@Query("gcmtitle") String categoryName,
|
||||
@QueryMap(encoded = true) Map<String, String> continuation);
|
||||
|
||||
@GET("w/api.php?action=query&format=json&formatversion=2"
|
||||
+ "&generator=categories&prop=info&gcllimit=50")
|
||||
Single<MwQueryResponse> getParentCategoryList(@Query("titles") String categoryName,
|
||||
@QueryMap(encoded = true) Map<String, String> continuation);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
|
||||
import io.reactivex.Single
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.QueryMap
|
||||
|
||||
/**
|
||||
* Interface for interacting with Commons category related APIs
|
||||
*/
|
||||
interface CategoryInterface {
|
||||
/**
|
||||
* Searches for categories with the specified name.
|
||||
*
|
||||
* @param filter The string to be searched
|
||||
* @param itemLimit How many results are returned
|
||||
* @return
|
||||
*/
|
||||
@GET(
|
||||
"w/api.php?action=query&format=json&formatversion=2&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70&gsrnamespace=14",
|
||||
)
|
||||
fun searchCategories(
|
||||
@Query("gsrsearch") filter: String?,
|
||||
@Query("gsrlimit") itemLimit: Int,
|
||||
@Query("gsroffset") offset: Int,
|
||||
): Single<MwQueryResponse>
|
||||
|
||||
/**
|
||||
* Searches for categories starting with the specified prefix.
|
||||
*
|
||||
* @param prefix The string to be searched
|
||||
* @param itemLimit How many results are returned
|
||||
* @return
|
||||
*/
|
||||
@GET(
|
||||
"w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70",
|
||||
)
|
||||
fun searchCategoriesForPrefix(
|
||||
@Query("gacprefix") prefix: String?,
|
||||
@Query("gaclimit") itemLimit: Int,
|
||||
@Query("gacoffset") offset: Int,
|
||||
): Single<MwQueryResponse>
|
||||
|
||||
/**
|
||||
* Fetches categories starting and ending with a specified name.
|
||||
*
|
||||
* @param startingCategory Name of the category to start
|
||||
* @param endingCategory Name of the category to end
|
||||
* @param itemLimit How many categories to return
|
||||
* @param offset offset
|
||||
* @return MwQueryResponse
|
||||
*/
|
||||
@GET(
|
||||
"w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70",
|
||||
)
|
||||
fun getCategoriesByName(
|
||||
@Query("gacfrom") startingCategory: String?,
|
||||
@Query("gacto") endingCategory: String?,
|
||||
@Query("gaclimit") itemLimit: Int,
|
||||
@Query("gacoffset") offset: Int,
|
||||
): Single<MwQueryResponse>
|
||||
|
||||
@GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50")
|
||||
fun getSubCategoryList(
|
||||
@Query("gcmtitle") categoryName: String,
|
||||
@QueryMap(encoded = true) continuation: Map<String, String>,
|
||||
): Single<MwQueryResponse>
|
||||
|
||||
@GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50")
|
||||
fun getParentCategoryList(
|
||||
@Query("titles") categoryName: String?,
|
||||
@QueryMap(encoded = true) continuation: Map<String, String>,
|
||||
): Single<MwQueryResponse>
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class CategoryItem(val name: String, val description: String,
|
||||
val thumbnail: String, var isSelected: Boolean) : Parcelable {
|
||||
|
||||
override fun toString(): String {
|
||||
return "CategoryItem: '$name'"
|
||||
}
|
||||
data class CategoryItem(
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val thumbnail: String?,
|
||||
var isSelected: Boolean,
|
||||
) : Parcelable {
|
||||
override fun toString(): String = "CategoryItem: '$name'"
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
|
@ -22,7 +23,5 @@ data class CategoryItem(val name: String, val description: String,
|
|||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return name.hashCode()
|
||||
}
|
||||
override fun hashCode(): Int = name.hashCode()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ package fr.free.nrw.commons.category
|
|||
|
||||
import io.reactivex.Single
|
||||
|
||||
|
||||
abstract class ContinuationClient<Network, Domain> {
|
||||
private val continuationStore: MutableMap<String, Map<String, String>?> = mutableMapOf()
|
||||
private val continuationExists: MutableMap<String, Boolean> = mutableMapOf()
|
||||
|
||||
private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true
|
||||
|
||||
fun continuationRequest(
|
||||
prefix: String,
|
||||
name: String,
|
||||
requestFunction: (Map<String, String>) -> Single<Network>
|
||||
requestFunction: (Map<String, String>) -> Single<Network>,
|
||||
): Single<List<Domain>> {
|
||||
val key = "$prefix$name"
|
||||
return if (hasMorePagesFor(key)) {
|
||||
|
|
@ -21,9 +21,15 @@ abstract class ContinuationClient<Network, Domain> {
|
|||
}
|
||||
}
|
||||
|
||||
abstract fun responseMapper(networkResult: Single<Network>, key: String?=null): Single<List<Domain>>
|
||||
abstract fun responseMapper(
|
||||
networkResult: Single<Network>,
|
||||
key: String? = null,
|
||||
): Single<List<Domain>>
|
||||
|
||||
fun handleContinuationResponse(continuation:Map<String,String>?, key:String?){
|
||||
fun handleContinuationResponse(
|
||||
continuation: Map<String, String>?,
|
||||
key: String?,
|
||||
) {
|
||||
if (key != null) {
|
||||
continuationExists[key] =
|
||||
continuation?.let { continuation ->
|
||||
|
|
@ -33,7 +39,10 @@ abstract class ContinuationClient<Network, Domain> {
|
|||
}
|
||||
}
|
||||
|
||||
protected fun resetContinuation(prefix: String, category: String) {
|
||||
protected fun resetContinuation(
|
||||
prefix: String,
|
||||
category: String,
|
||||
) {
|
||||
continuationExists.remove("$prefix$category")
|
||||
continuationStore.remove("$prefix$category")
|
||||
}
|
||||
|
|
@ -44,9 +53,11 @@ abstract class ContinuationClient<Network, Domain> {
|
|||
* @param prefix
|
||||
* @param userName the username
|
||||
*/
|
||||
protected fun resetUserContinuation(prefix: String, userName: String) {
|
||||
protected fun resetUserContinuation(
|
||||
prefix: String,
|
||||
userName: String,
|
||||
) {
|
||||
continuationExists.remove("$prefix$userName")
|
||||
continuationStore.remove("$prefix$userName")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.facebook.drawee.view.SimpleDraweeView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import fr.free.nrw.commons.Media;
|
||||
import fr.free.nrw.commons.R;
|
||||
|
||||
/**
|
||||
* This is created to only display UI implementation. Needs to be changed in real implementation
|
||||
*/
|
||||
|
||||
public class GridViewAdapter extends ArrayAdapter {
|
||||
private List<Media> data;
|
||||
|
||||
public GridViewAdapter(Context context, int layoutResourceId, List<Media> data) {
|
||||
super(context, layoutResourceId, data);
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds more item to the list
|
||||
* Its triggered on scrolling down in the list
|
||||
* @param images
|
||||
*/
|
||||
public void addItems(List<Media> images) {
|
||||
if (data == null) {
|
||||
data = new ArrayList<>();
|
||||
}
|
||||
data.addAll(images);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the first item in the new list with old list and returns true if they are same
|
||||
* Its triggered on successful response of the fetch images API.
|
||||
* @param images
|
||||
*/
|
||||
public boolean containsAll(List<Media> images){
|
||||
if (images == null || images.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (data == null) {
|
||||
data = new ArrayList<>();
|
||||
return false;
|
||||
}
|
||||
if (data.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String fileName = data.get(0).getFilename();
|
||||
String imageName = images.get(0).getFilename();
|
||||
return imageName.equals(fileName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return data == null || data.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the UI for the category image item
|
||||
* @param position
|
||||
* @param convertView
|
||||
* @param parent
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public View getView(int position, View convertView, ViewGroup parent) {
|
||||
|
||||
if (convertView == null) {
|
||||
convertView = LayoutInflater.from(getContext()).inflate(R.layout.layout_category_images, null);
|
||||
}
|
||||
|
||||
Media item = data.get(position);
|
||||
SimpleDraweeView imageView = convertView.findViewById(R.id.categoryImageView);
|
||||
TextView fileName = convertView.findViewById(R.id.categoryImageTitle);
|
||||
TextView uploader = convertView.findViewById(R.id.categoryImageAuthor);
|
||||
fileName.setText(item.getMostRelevantCaption());
|
||||
setUploaderView(item, uploader);
|
||||
imageView.setImageURI(item.getThumbUrl());
|
||||
return convertView;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the Media item at the given position
|
||||
*/
|
||||
@Nullable
|
||||
@Override
|
||||
public Media getItem(int position) {
|
||||
return data.get(position);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shows author information if its present
|
||||
* @param item
|
||||
* @param uploader
|
||||
*/
|
||||
private void setUploaderView(Media item, TextView uploader) {
|
||||
if (!TextUtils.isEmpty(item.getAuthor())) {
|
||||
uploader.setVisibility(View.VISIBLE);
|
||||
uploader.setText(getContext().getString(R.string.image_uploaded_by, item.getUser()));
|
||||
} else {
|
||||
uploader.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package fr.free.nrw.commons.category
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.TextView
|
||||
import com.facebook.drawee.view.SimpleDraweeView
|
||||
import fr.free.nrw.commons.Media
|
||||
import fr.free.nrw.commons.R
|
||||
|
||||
|
||||
/**
|
||||
* This is created to only display UI implementation. Needs to be changed in real implementation
|
||||
*/
|
||||
class GridViewAdapter(
|
||||
context: Context,
|
||||
layoutResourceId: Int,
|
||||
private var data: MutableList<Media>?
|
||||
) : ArrayAdapter<Media>(context, layoutResourceId, data ?: mutableListOf()) {
|
||||
|
||||
/**
|
||||
* Adds more items to the list
|
||||
* It's triggered on scrolling down in the list
|
||||
* @param images
|
||||
*/
|
||||
fun addItems(images: List<Media>) {
|
||||
if (data == null) {
|
||||
data = mutableListOf()
|
||||
}
|
||||
data?.addAll(images)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the first item in the new list with the old list and returns true if they are the same
|
||||
* It's triggered on a successful response of the fetch images API.
|
||||
* @param images
|
||||
*/
|
||||
fun containsAll(images: List<Media>?): Boolean {
|
||||
if (images.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
if (data.isNullOrEmpty()) {
|
||||
data = mutableListOf()
|
||||
return false
|
||||
}
|
||||
val fileName = data?.get(0)?.filename
|
||||
val imageName = images[0].filename
|
||||
return imageName == fileName
|
||||
}
|
||||
|
||||
override fun isEmpty(): Boolean {
|
||||
return data.isNullOrEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the UI for the category image item
|
||||
* @param position
|
||||
* @param convertView
|
||||
* @param parent
|
||||
* @return
|
||||
*/
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(
|
||||
R.layout.layout_category_images,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
|
||||
val item = data?.get(position)
|
||||
val imageView = view.findViewById<SimpleDraweeView>(R.id.categoryImageView)
|
||||
val fileName = view.findViewById<TextView>(R.id.categoryImageTitle)
|
||||
val uploader = view.findViewById<TextView>(R.id.categoryImageAuthor)
|
||||
|
||||
item?.let {
|
||||
fileName.text = it.mostRelevantCaption
|
||||
setUploaderView(it, uploader)
|
||||
imageView.setImageURI(it.thumbUrl)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the Media item at the given position
|
||||
*/
|
||||
override fun getItem(position: Int): Media? {
|
||||
return data?.get(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows author information if it's present
|
||||
* @param item
|
||||
* @param uploader
|
||||
*/
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
private fun setUploaderView(item: Media, uploader: TextView) {
|
||||
if (!item.author.isNullOrEmpty()) {
|
||||
uploader.visibility = View.VISIBLE
|
||||
uploader.text = context.getString(
|
||||
R.string.image_uploaded_by,
|
||||
item.user
|
||||
)
|
||||
} else {
|
||||
uploader.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package fr.free.nrw.commons.category;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface OnCategoriesSaveHandler {
|
||||
void onCategoriesSave(List<String> categories);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue