diff --git a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt index eba4a55f4..c9eb7d2f1 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/SessionManager.kt @@ -62,7 +62,7 @@ class SessionManager @Inject constructor( fun forceLogin(context: Context?) = context?.let { LoginActivity.startYourself(it) } - fun getPreference(key: String?): Boolean = + fun getPreference(key: String): Boolean = defaultKvStore.getBoolean(key) fun logout(): Completable = Completable.fromObservable( diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java deleted file mode 100644 index d1ee4c8b0..000000000 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.java +++ /dev/null @@ -1,118 +0,0 @@ -package fr.free.nrw.commons.campaigns; - -import android.content.Context; -import android.net.Uri; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import fr.free.nrw.commons.campaigns.models.Campaign; -import fr.free.nrw.commons.databinding.LayoutCampaginBinding; -import fr.free.nrw.commons.theme.BaseActivity; -import fr.free.nrw.commons.utils.CommonsDateUtil; - -import fr.free.nrw.commons.utils.DateUtil; -import java.text.ParseException; -import java.util.Date; - -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.contributions.MainActivity; -import fr.free.nrw.commons.utils.SwipableCardView; -import fr.free.nrw.commons.utils.ViewUtil; - -/** - * A view which represents a single campaign - */ -public class CampaignView extends SwipableCardView { - Campaign campaign; - private LayoutCampaginBinding binding; - private ViewHolder viewHolder; - - public static final String CAMPAIGNS_DEFAULT_PREFERENCE = "displayCampaignsCardView"; - public static final String WLM_CARD_PREFERENCE = "displayWLMCardView"; - - private String campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE; - - public CampaignView(@NonNull Context context) { - super(context); - init(); - } - - public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(); - } - - public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(); - } - - public void setCampaign(final Campaign campaign) { - this.campaign = campaign; - if (campaign != null) { - if (campaign.isWLMCampaign()) { - campaignPreference = WLM_CARD_PREFERENCE; - } - setVisibility(View.VISIBLE); - viewHolder.init(); - } else { - this.setVisibility(View.GONE); - } - } - - @Override public boolean onSwipe(final View view) { - view.setVisibility(View.GONE); - ((BaseActivity) getContext()).defaultKvStore - .putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false); - ViewUtil.showLongToast(getContext(), - getResources().getString(R.string.nearby_campaign_dismiss_message)); - return true; - } - - private void init() { - binding = LayoutCampaginBinding.inflate(LayoutInflater.from(getContext()), this, true); - viewHolder = new ViewHolder(); - setOnClickListener(view -> { - if (campaign != null) { - if (campaign.isWLMCampaign()) { - ((MainActivity)(getContext())).showNearby(); - } else { - Utils.handleWebUrl(getContext(), Uri.parse(campaign.getLink())); - } - } - }); - } - - public class ViewHolder { - public void init() { - if (campaign != null) { - binding.ivCampaign.setImageDrawable( - getResources().getDrawable(R.drawable.ic_campaign)); - - binding.tvTitle.setText(campaign.getTitle()); - binding.tvDescription.setText(campaign.getDescription()); - try { - if (campaign.isWLMCampaign()) { - binding.tvDates.setText( - String.format("%1s - %2s", campaign.getStartDate(), - campaign.getEndDate())); - } else { - final Date startDate = CommonsDateUtil.getIso8601DateFormatShort() - .parse(campaign.getStartDate()); - final Date endDate = CommonsDateUtil.getIso8601DateFormatShort() - .parse(campaign.getEndDate()); - binding.tvDates.setText(String.format("%1s - %2s", DateUtil.getExtraShortDateString(startDate), - DateUtil.getExtraShortDateString(endDate))); - } - } catch (final ParseException e) { - e.printStackTrace(); - } - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt new file mode 100644 index 000000000..7a4720177 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignView.kt @@ -0,0 +1,121 @@ +package fr.free.nrw.commons.campaigns + +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import androidx.core.content.ContextCompat +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.campaigns.models.Campaign +import fr.free.nrw.commons.contributions.MainActivity +import fr.free.nrw.commons.databinding.LayoutCampaginBinding +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort +import fr.free.nrw.commons.utils.DateUtil.getExtraShortDateString +import fr.free.nrw.commons.utils.SwipableCardView +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import timber.log.Timber +import java.text.ParseException + +/** + * A view which represents a single campaign + */ +class CampaignView : SwipableCardView { + private var campaign: Campaign? = null + private var binding: LayoutCampaginBinding? = null + private var viewHolder: ViewHolder? = null + private var campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE + + constructor(context: Context) : super(context) { + init() + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + init() + } + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, attrs, defStyleAttr) { + init() + } + + fun setCampaign(campaign: Campaign?) { + this.campaign = campaign + if (campaign != null) { + if (campaign.isWLMCampaign) { + campaignPreference = WLM_CARD_PREFERENCE + } + visibility = VISIBLE + viewHolder!!.init() + } else { + visibility = GONE + } + } + + override fun onSwipe(view: View): Boolean { + view.visibility = GONE + (context as BaseActivity).defaultKvStore.putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false) + showLongToast( + context, + resources.getString(R.string.nearby_campaign_dismiss_message) + ) + return true + } + + private fun init() { + binding = LayoutCampaginBinding.inflate( + LayoutInflater.from(context), this, true + ) + viewHolder = ViewHolder() + setOnClickListener { + campaign?.let { + if (it.isWLMCampaign) { + ((context) as MainActivity).showNearby() + } else { + Utils.handleWebUrl(context, Uri.parse(it.link)) + } + } + } + } + + inner class ViewHolder { + fun init() { + if (campaign != null) { + binding!!.ivCampaign.setImageDrawable( + ContextCompat.getDrawable(binding!!.root.context, R.drawable.ic_campaign) + ) + binding!!.tvTitle.text = campaign!!.title + binding!!.tvDescription.text = campaign!!.description + try { + if (campaign!!.isWLMCampaign) { + binding!!.tvDates.text = String.format( + "%1s - %2s", campaign!!.startDate, + campaign!!.endDate + ) + } else { + val startDate = getIso8601DateFormatShort().parse( + campaign?.startDate + ) + val endDate = getIso8601DateFormatShort().parse( + campaign?.endDate + ) + binding!!.tvDates.text = String.format( + "%1s - %2s", getExtraShortDateString( + startDate!! + ), getExtraShortDateString(endDate!!) + ) + } + } catch (e: ParseException) { + Timber.e(e) + } + } + } + } + + companion object { + const val CAMPAIGNS_DEFAULT_PREFERENCE: String = "displayCampaignsCardView" + const val WLM_CARD_PREFERENCE: String = "displayWLMCardView" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java deleted file mode 100644 index 157047774..000000000 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.java +++ /dev/null @@ -1,123 +0,0 @@ -package fr.free.nrw.commons.campaigns; - -import android.annotation.SuppressLint; - -import fr.free.nrw.commons.campaigns.models.Campaign; -import fr.free.nrw.commons.utils.CommonsDateUtil; -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 io.reactivex.Scheduler; -import io.reactivex.Single; -import io.reactivex.SingleObserver; -import io.reactivex.disposables.Disposable; -import timber.log.Timber; - -import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; -import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD; - -/** - * The presenter for the campaigns view, fetches the campaigns from the api and informs the view on - * success and error - */ -@Singleton -public class CampaignsPresenter implements BasePresenter { - private final OkHttpJsonApiClient okHttpJsonApiClient; - private final Scheduler mainThreadScheduler; - private final Scheduler ioScheduler; - - private ICampaignsView view; - private Disposable disposable; - private Campaign campaign; - - @Inject - public CampaignsPresenter(OkHttpJsonApiClient okHttpJsonApiClient, @Named(IO_THREAD)Scheduler ioScheduler, @Named(MAIN_THREAD)Scheduler mainThreadScheduler) { - this.okHttpJsonApiClient = okHttpJsonApiClient; - this.mainThreadScheduler=mainThreadScheduler; - this.ioScheduler=ioScheduler; - } - - @Override - public void onAttachView(ICampaignsView view) { - this.view = view; - } - - @Override public void onDetachView() { - this.view = null; - if (disposable != null) { - disposable.dispose(); - } - } - - /** - * make the api call to fetch the campaigns - */ - @SuppressLint("CheckResult") - public void getCampaigns() { - if (view != null && okHttpJsonApiClient != null) { - //If we already have a campaign, lets not make another call - if (this.campaign != null) { - view.showCampaigns(campaign); - return; - } - Single campaigns = okHttpJsonApiClient.getCampaigns(); - campaigns.observeOn(mainThreadScheduler) - .subscribeOn(ioScheduler) - .subscribeWith(new SingleObserver() { - - @Override public void onSubscribe(Disposable d) { - disposable = d; - } - - @Override public void onSuccess(CampaignResponseDTO campaignResponseDTO) { - List campaigns = campaignResponseDTO.getCampaigns(); - if (campaigns == null || campaigns.isEmpty()) { - Timber.e("The campaigns list is empty"); - view.showCampaigns(null); - return; - } - Collections.sort(campaigns, (campaign, t1) -> { - Date date1, date2; - try { - - date1 = CommonsDateUtil.getIso8601DateFormatShort().parse(campaign.getStartDate()); - date2 = CommonsDateUtil.getIso8601DateFormatShort().parse(t1.getStartDate()); - } catch (ParseException e) { - e.printStackTrace(); - return -1; - } - return date1.compareTo(date2); - }); - Date campaignEndDate, campaignStartDate; - Date currentDate = new Date(); - try { - for (Campaign aCampaign : campaigns) { - campaignEndDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getEndDate()); - campaignStartDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getStartDate()); - if (campaignEndDate.compareTo(currentDate) >= 0 - && campaignStartDate.compareTo(currentDate) <= 0) { - campaign = aCampaign; - break; - } - } - } catch (ParseException e) { - e.printStackTrace(); - } - view.showCampaigns(campaign); - } - - @Override public void onError(Throwable e) { - Timber.e(e, "could not fetch campaigns"); - } - }); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt new file mode 100644 index 000000000..3753dfb67 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt @@ -0,0 +1,107 @@ +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 +import fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD +import fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort +import io.reactivex.Scheduler +import io.reactivex.disposables.Disposable +import timber.log.Timber +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * The presenter for the campaigns view, fetches the campaigns from the api and informs the view on + * success and error + */ +@Singleton +class CampaignsPresenter @Inject constructor( + private val okHttpJsonApiClient: OkHttpJsonApiClient?, + @param:Named(IO_THREAD) private val ioScheduler: Scheduler, + @param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler +) : BasePresenter { + private var view: ICampaignsView? = null + private var disposable: Disposable? = null + private var campaign: Campaign? = null + + override fun onAttachView(view: ICampaignsView) { + this.view = view + } + + override fun onDetachView() { + view = null + disposable?.dispose() + } + + /** + * make the api call to fetch the campaigns + */ + @SuppressLint("CheckResult") + fun getCampaigns() { + if (view != null && okHttpJsonApiClient != null) { + //If we already have a campaign, lets not make another call + if (campaign != null) { + view!!.showCampaigns(campaign) + return + } + + okHttpJsonApiClient.campaigns + .observeOn(mainThreadScheduler) + .subscribeOn(ioScheduler) + .doOnSubscribe { disposable = it } + .subscribe({ campaignResponseDTO -> + val campaigns = campaignResponseDTO.campaigns?.toMutableList() + if (campaigns.isNullOrEmpty()) { + Timber.e("The campaigns list is empty") + view!!.showCampaigns(null) + } else { + sortCampaignsByStartDate(campaigns) + campaign = findActiveCampaign(campaigns) + view!!.showCampaigns(campaign) + } + }, { + Timber.e(it, "could not fetch campaigns") + }) + } + } + + private fun sortCampaignsByStartDate(campaigns: MutableList) { + val dateFormat: SimpleDateFormat = getIso8601DateFormatShort() + campaigns.sortWith(Comparator { campaign: Campaign, other: Campaign -> + val date1: Date? + val date2: Date? + try { + date1 = campaign.startDate?.let { dateFormat.parse(it) } + date2 = other.startDate?.let { dateFormat.parse(it) } + } catch (e: ParseException) { + Timber.e(e) + return@Comparator -1 + } + if (date1 != null && date2 != null) date1.compareTo(date2) else -1 + }) + } + + private fun findActiveCampaign(campaigns: List) : Campaign? { + val dateFormat: SimpleDateFormat = getIso8601DateFormatShort() + val currentDate = Date() + return try { + campaigns.firstOrNull { + val campaignStartDate = it.startDate?.let { s -> dateFormat.parse(s) } + val campaignEndDate = it.endDate?.let { s -> dateFormat.parse(s) } + campaignStartDate != null && campaignEndDate != null && + campaignEndDate >= currentDate && campaignStartDate <= currentDate + } + } catch (e: ParseException) { + Timber.e(e, "could not find active campaign") + null + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java deleted file mode 100644 index a1e79cca6..000000000 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.java +++ /dev/null @@ -1,11 +0,0 @@ -package fr.free.nrw.commons.campaigns; - -import fr.free.nrw.commons.MvpView; -import fr.free.nrw.commons.campaigns.models.Campaign; - -/** - * Interface which defines the view contracts of the campaign view - */ -public interface ICampaignsView extends MvpView { - void showCampaigns(Campaign campaign); -} diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt new file mode 100644 index 000000000..62a19aaac --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/ICampaignsView.kt @@ -0,0 +1,11 @@ +package fr.free.nrw.commons.campaigns + +import fr.free.nrw.commons.MvpView +import fr.free.nrw.commons.campaigns.models.Campaign + +/** + * Interface which defines the view contracts of the campaign view + */ +interface ICampaignsView : MvpView { + fun showCampaigns(campaign: Campaign?) +} diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.java b/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.java deleted file mode 100644 index 032898896..000000000 --- a/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.java +++ /dev/null @@ -1,215 +0,0 @@ -package fr.free.nrw.commons.kvstore; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.Nullable; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -import timber.log.Timber; - -public class BasicKvStore implements KeyValueStore { - private static final String KEY_VERSION = "__version__"; - /* - This class only performs puts, sets and clears. - A commit returns a boolean indicating whether it has succeeded, we are not throwing an exception as it will - require the dev to handle it in every usage - instead we will pass on this boolean so it can be evaluated if needed. - */ - private final SharedPreferences _store; - - public BasicKvStore(Context context, String storeName) { - _store = context.getSharedPreferences(storeName, Context.MODE_PRIVATE); - } - - /** - * If you don't want onVersionUpdate to be called on a fresh creation, the first version supplied for the kvstore should be set to 0. - */ - public BasicKvStore(Context context, String storeName, int version) { - this(context,storeName,version,false); - } - - public BasicKvStore(Context context, String storeName, int version, boolean clearAllOnUpgrade) { - _store = context.getSharedPreferences(storeName, Context.MODE_PRIVATE); - int oldVersion = getInt(KEY_VERSION); - - if (version > oldVersion) { - Timber.i("version updated from %s to %s, with clearFlag %b", oldVersion, version, clearAllOnUpgrade); - onVersionUpdate(oldVersion, version, clearAllOnUpgrade); - } - - if (version < oldVersion) { - throw new IllegalArgumentException( - "kvstore downgrade not allowed, old version:" + oldVersion + ", new version: " + - version); - } - //Keep this statement at the end so that clearing of store does not cause version also to get removed. - putIntInternal(KEY_VERSION, version); - } - - public void onVersionUpdate(int oldVersion, int version, boolean clearAllFlag) { - if(clearAllFlag) { - clearAll(); - } - } - - public Set getKeySet() { - Map allContents = new HashMap<>(_store.getAll()); - allContents.remove(KEY_VERSION); - return allContents.keySet(); - } - - @Nullable - public Map getAll() { - Map allContents = _store.getAll(); - if (allContents == null || allContents.size() == 0) { - return null; - } - allContents.remove(KEY_VERSION); - return new HashMap<>(allContents); - } - - @Override - public String getString(String key) { - return getString(key, null); - } - - @Override - public boolean getBoolean(String key) { - return getBoolean(key, false); - } - - @Override - public long getLong(String key) { - return getLong(key, 0); - } - - @Override - public int getInt(String key) { - return getInt(key, 0); - } - - @Override - public String getString(String key, String defaultValue) { - return _store.getString(key, defaultValue); - } - - @Override - public boolean getBoolean(String key, boolean defaultValue) { - return _store.getBoolean(key, defaultValue); - } - - @Override - public long getLong(String key, long defaultValue) { - return _store.getLong(key, defaultValue); - } - - @Override - public int getInt(String key, int defaultValue) { - return _store.getInt(key, defaultValue); - } - - public void putAllStrings(Map keyValuePairs) { - SharedPreferences.Editor editor = _store.edit(); - for (Map.Entry keyValuePair : keyValuePairs.entrySet()) { - putString(editor, keyValuePair.getKey(), keyValuePair.getValue(), false); - } - editor.apply(); - } - - @Override - public void putString(String key, String value) { - SharedPreferences.Editor editor = _store.edit(); - putString(editor, key, value, true); - } - - private void putString(SharedPreferences.Editor editor, String key, String value, - boolean commit) { - assertKeyNotReserved(key); - editor.putString(key, value); - if(commit) { - editor.apply(); - } - } - - @Override - public void putBoolean(String key, boolean value) { - assertKeyNotReserved(key); - SharedPreferences.Editor editor = _store.edit(); - editor.putBoolean(key, value); - editor.apply(); - } - - @Override - public void putLong(String key, long value) { - assertKeyNotReserved(key); - SharedPreferences.Editor editor = _store.edit(); - editor.putLong(key, value); - editor.apply(); - } - - @Override - public void putInt(String key, int value) { - assertKeyNotReserved(key); - putIntInternal(key, value); - } - - @Override - public boolean contains(String key) { - return _store.contains(key); - } - - @Override - public void remove(String key) { - SharedPreferences.Editor editor = _store.edit(); - editor.remove(key); - editor.apply(); - } - - @Override - public void clearAll() { - int version = getInt(KEY_VERSION); - SharedPreferences.Editor editor = _store.edit(); - editor.clear(); - editor.apply(); - putIntInternal(KEY_VERSION, version); - } - - @Override - public void clearAllWithVersion() { - SharedPreferences.Editor editor = _store.edit(); - editor.clear(); - editor.apply(); - } - - private void putIntInternal(String key, int value) { - SharedPreferences.Editor editor = _store.edit(); - editor.putInt(key, value); - editor.apply(); - } - - private void assertKeyNotReserved(String key) { - if (key.equals(KEY_VERSION)) { - throw new IllegalArgumentException(key + "is a reserved key"); - } - } - - public void registerChangeListener(SharedPreferences.OnSharedPreferenceChangeListener l) { - _store.registerOnSharedPreferenceChangeListener(l); - } - - public void unregisterChangeListener(SharedPreferences.OnSharedPreferenceChangeListener l) { - _store.unregisterOnSharedPreferenceChangeListener(l); - } - - public Set getStringSet(String key){ - return _store.getStringSet(key, new HashSet<>()); - } - - public void putStringSet(String key,Set value){ - _store.edit().putStringSet(key,value).apply(); - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.kt b/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.kt new file mode 100644 index 000000000..e0b860164 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.kt @@ -0,0 +1,152 @@ +package fr.free.nrw.commons.kvstore + +import android.content.Context +import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting +import androidx.core.content.edit +import timber.log.Timber + +open class BasicKvStore : KeyValueStore { + /* + This class only performs puts, sets and clears. + A commit returns a boolean indicating whether it has succeeded, we are not throwing an exception as it will + require the dev to handle it in every usage - instead we will pass on this boolean so it can be evaluated if needed. + */ + private val _store: SharedPreferences + + constructor(context: Context, storeName: String?) { + _store = context.getSharedPreferences(storeName, Context.MODE_PRIVATE) + } + + /** + * If you don't want onVersionUpdate to be called on a fresh creation, the first version supplied for the kvstore should be set to 0. + */ + @JvmOverloads + constructor( + context: Context, + storeName: String?, + version: Int, + clearAllOnUpgrade: Boolean = false + ) { + _store = context.getSharedPreferences(storeName, Context.MODE_PRIVATE) + val oldVersion = _store.getInt(KEY_VERSION, 0) + + require(version >= oldVersion) { + "kvstore downgrade not allowed, old version:" + oldVersion + ", new version: " + + version + } + + if (version > oldVersion) { + Timber.i( + "version updated from %s to %s, with clearFlag %b", + oldVersion, + version, + clearAllOnUpgrade + ) + onVersionUpdate(oldVersion, version, clearAllOnUpgrade) + } + + //Keep this statement at the end so that clearing of store does not cause version also to get removed. + _store.edit { putInt(KEY_VERSION, version) } + } + + val all: Map? + get() { + val allContents = _store.all + if (allContents == null || allContents.isEmpty()) { + return null + } + allContents.remove(KEY_VERSION) + return HashMap(allContents) + } + + override fun getString(key: String): String? = + getString(key, null) + + override fun getBoolean(key: String): Boolean = + getBoolean(key, false) + + override fun getLong(key: String): Long = + getLong(key, 0) + + override fun getInt(key: String): Int = + getInt(key, 0) + + fun getStringSet(key: String?): MutableSet = + _store.getStringSet(key, HashSet())!! + + override fun getString(key: String, defaultValue: String?): String? = + _store.getString(key, defaultValue) + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean = + _store.getBoolean(key, defaultValue) + + override fun getLong(key: String, defaultValue: Long): Long = + _store.getLong(key, defaultValue) + + override fun getInt(key: String, defaultValue: Int): Int = + _store.getInt(key, defaultValue) + + fun putAllStrings(kvData: Map) = assertKeyNotReserved(kvData.keys) { + for ((key, value) in kvData) { + putString(key, value) + } + } + + override fun putString(key: String, value: String) = assertKeyNotReserved(key) { + putString(key, value) + } + + override fun putBoolean(key: String, value: Boolean) = assertKeyNotReserved(key) { + putBoolean(key, value) + } + + override fun putLong(key: String, value: Long) = assertKeyNotReserved(key) { + putLong(key, value) + } + + override fun putInt(key: String, value: Int) = assertKeyNotReserved(key) { + putInt(key, value) + } + + fun putStringSet(key: String?, value: Set?) = + _store.edit{ putStringSet(key, value) } + + override fun remove(key: String) = assertKeyNotReserved(key) { + remove(key) + } + + override fun contains(key: String): Boolean { + if (key == KEY_VERSION) return false + return _store.contains(key) + } + + override fun clearAll() { + val version = _store.getInt(KEY_VERSION, 0) + _store.edit { + clear() + putInt(KEY_VERSION, version) + } + } + + private fun onVersionUpdate(oldVersion: Int, version: Int, clearAllFlag: Boolean) { + if (clearAllFlag) { + clearAll() + } + } + + protected fun assertKeyNotReserved(key: Set, block: SharedPreferences.Editor.() -> Unit) { + key.forEach { require(it != KEY_VERSION) { "$it is a reserved key" } } + _store.edit { block(this) } + } + + protected fun assertKeyNotReserved(key: String, block: SharedPreferences.Editor.() -> Unit) { + require(key != KEY_VERSION) { "$key is a reserved key" } + _store.edit { block(this) } + } + + companion object { + @VisibleForTesting + const val KEY_VERSION: String = "__version__" + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.java b/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.java deleted file mode 100644 index d612880d9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.java +++ /dev/null @@ -1,68 +0,0 @@ -package fr.free.nrw.commons.kvstore; - -import android.content.Context; - -import androidx.annotation.Nullable; - -import com.google.gson.Gson; -import com.google.gson.JsonSyntaxException; - -import java.lang.reflect.Type; -import java.util.HashMap; -import java.util.Map; - -public class JsonKvStore extends BasicKvStore { - private final Gson gson; - - public JsonKvStore(Context context, String storeName, Gson gson) { - super(context, storeName); - this.gson = gson; - } - - public JsonKvStore(Context context, String storeName, int version, Gson gson) { - super(context, storeName, version); - this.gson = gson; - } - - public JsonKvStore(Context context, String storeName, int version, boolean clearAllOnUpgrade, Gson gson) { - super(context, storeName, version, clearAllOnUpgrade); - this.gson = gson; - } - - public void putAllJsons(Map jsonMap) { - Map stringsMap = new HashMap<>(jsonMap.size()); - for (Map.Entry keyValuePair : jsonMap.entrySet()) { - String jsonString = gson.toJson(keyValuePair.getValue()); - stringsMap.put(keyValuePair.getKey(), jsonString); - } - putAllStrings(stringsMap); - } - - public void putJson(String key, T object) { - putString(key, gson.toJson(object)); - } - - public void putJsonWithTypeInfo(String key, T object, Type type) { - putString(key, gson.toJson(object, type)); - } - - @Nullable - public T getJson(String key, Class clazz) { - String jsonString = getString(key); - try { - return gson.fromJson(jsonString, clazz); - } catch (JsonSyntaxException e) { - return null; - } - } - - @Nullable - public T getJson(String key, Type type) { - String jsonString = getString(key); - try { - return gson.fromJson(jsonString, type); - } catch (JsonSyntaxException e) { - return null; - } - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.kt b/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.kt new file mode 100644 index 000000000..0f46222a4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.kt @@ -0,0 +1,52 @@ +package fr.free.nrw.commons.kvstore + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException + +class JsonKvStore : BasicKvStore { + val gson: Gson + + constructor(context: Context, storeName: String?, gson: Gson) : super(context, storeName) { + this.gson = gson + } + + constructor(context: Context, storeName: String?, version: Int, gson: Gson) : super( + context, storeName, version + ) { + this.gson = gson + } + + constructor( + context: Context, + storeName: String?, + version: Int, + clearAllOnUpgrade: Boolean, + gson: Gson + ) : super(context, storeName, version, clearAllOnUpgrade) { + this.gson = gson + } + + fun putJson(key: String, value: T) = assertKeyNotReserved(key) { + putString(key, gson.toJson(value)) + } + + @Deprecated( + message = "Migrate to newer Kotlin syntax", + replaceWith = ReplaceWith("getJson(key)") + ) + fun getJson(key: String, clazz: Class?): T? = try { + gson.fromJson(getString(key), clazz) + } catch (e: JsonSyntaxException) { + null + } + + // Later, when the calls are coming from Kotlin, this will allow us to + // drop the "clazz" parameter, and just pick up the type at the call site. + // The deprecation warning should help migration! + inline fun getJson(key: String): T? = try { + gson.fromJson(getString(key), T::class.java) + } catch (e: JsonSyntaxException) { + null + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.java b/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.java deleted file mode 100644 index 46d6d8f81..000000000 --- a/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.java +++ /dev/null @@ -1,35 +0,0 @@ -package fr.free.nrw.commons.kvstore; - -public interface KeyValueStore { - String getString(String key); - - boolean getBoolean(String key); - - long getLong(String key); - - int getInt(String key); - - String getString(String key, String defaultValue); - - boolean getBoolean(String key, boolean defaultValue); - - long getLong(String key, long defaultValue); - - int getInt(String key, int defaultValue); - - void putString(String key, String value); - - void putBoolean(String key, boolean value); - - void putLong(String key, long value); - - void putInt(String key, int value); - - boolean contains(String key); - - void remove(String key); - - void clearAll(); - - void clearAllWithVersion(); -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.kt b/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.kt new file mode 100644 index 000000000..6e19901cb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/kvstore/KeyValueStore.kt @@ -0,0 +1,33 @@ +package fr.free.nrw.commons.kvstore + +interface KeyValueStore { + fun getString(key: String): String? + + fun getBoolean(key: String): Boolean + + fun getLong(key: String): Long + + fun getInt(key: String): Int + + fun getString(key: String, defaultValue: String?): String? + + fun getBoolean(key: String, defaultValue: Boolean): Boolean + + fun getLong(key: String, defaultValue: Long): Long + + fun getInt(key: String, defaultValue: Int): Int + + fun putString(key: String, value: String) + + fun putBoolean(key: String, value: Boolean) + + fun putLong(key: String, value: Long) + + fun putInt(key: String, value: Int) + + fun contains(key: String): Boolean + + fun remove(key: String) + + fun clearAll() +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.java b/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.java deleted file mode 100644 index 29c2c732e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.java +++ /dev/null @@ -1,105 +0,0 @@ -package fr.free.nrw.commons.logging; - -import android.content.Context; - -import android.os.Bundle; -import javax.inject.Inject; -import javax.inject.Singleton; - -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.DeviceInfoUtil; -import org.acra.data.CrashReportData; -import org.acra.sender.ReportSenderException; -import org.jetbrains.annotations.NotNull; - -/** - * Class responsible for sending logs to developers - */ -@Singleton -public class CommonsLogSender extends LogsSender { - private static final String LOGS_PRIVATE_EMAIL = "commons-app-android-private@googlegroups.com"; - private static final String LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Android App (%s) Logs"; - private static final String BETA_LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Beta Android App (%s) Logs"; - - private SessionManager sessionManager; - private Context context; - - @Inject - public CommonsLogSender(SessionManager sessionManager, - Context context) { - super(sessionManager); - - this.sessionManager = sessionManager; - this.context = context; - boolean isBeta = ConfigUtils.isBetaFlavour(); - this.logFileName = isBeta ? "CommonsBetaAppLogs.zip" : "CommonsAppLogs.zip"; - String emailSubjectFormat = isBeta ? BETA_LOGS_PRIVATE_EMAIL_SUBJECT : LOGS_PRIVATE_EMAIL_SUBJECT; - this.emailSubject = String.format(emailSubjectFormat, sessionManager.getUserName()); - this.emailBody = getExtraInfo(); - this.mailTo = LOGS_PRIVATE_EMAIL; - } - - /** - * Attach any extra meta information about user or device that might help in debugging - * @return String with extra meta information useful for debugging - */ - @Override - public String getExtraInfo() { - StringBuilder builder = new StringBuilder(); - - // Getting API Level - builder.append("API level: ") - .append(DeviceInfoUtil.getAPILevel()) - .append("\n"); - - // Getting Android Version - builder.append("Android version: ") - .append(DeviceInfoUtil.getAndroidVersion()) - .append("\n"); - - // Getting Device Manufacturer - builder.append("Device manufacturer: ") - .append(DeviceInfoUtil.getDeviceManufacturer()) - .append("\n"); - - // Getting Device Model - builder.append("Device model: ") - .append(DeviceInfoUtil.getDeviceModel()) - .append("\n"); - - // Getting Device Name - builder.append("Device: ") - .append(DeviceInfoUtil.getDevice()) - .append("\n"); - - // Getting Network Type - builder.append("Network type: ") - .append(DeviceInfoUtil.getConnectionType(context)) - .append("\n"); - - // Getting App Version - builder.append("App version name: ") - .append(ConfigUtils.getVersionNameWithSha(context)) - .append("\n"); - - // Getting Username - builder.append("User name: ") - .append(sessionManager.getUserName()) - .append("\n"); - - - return builder.toString(); - } - - @Override - public boolean requiresForeground() { - return false; - } - - @Override - public void send(@NotNull Context context, @NotNull CrashReportData crashReportData, - @NotNull Bundle bundle) throws ReportSenderException { - - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.kt b/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.kt new file mode 100644 index 000000000..7c6b988a6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.kt @@ -0,0 +1,107 @@ +package fr.free.nrw.commons.logging + +import android.content.Context + +import android.os.Bundle +import javax.inject.Inject +import javax.inject.Singleton + +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.utils.ConfigUtils +import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha +import fr.free.nrw.commons.utils.DeviceInfoUtil +import org.acra.data.CrashReportData + + +/** + * Class responsible for sending logs to developers + */ +@Singleton +class CommonsLogSender @Inject constructor( + private val sessionManager: SessionManager, + private val context: Context +) : LogsSender(sessionManager) { + + + companion object { + private const val LOGS_PRIVATE_EMAIL = "commons-app-android-private@googlegroups.com" + private const val LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Android App (%s) Logs" + private const val BETA_LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Beta Android App (%s) Logs" + } + + init { + val isBeta = ConfigUtils.isBetaFlavour + logFileName = if (isBeta) "CommonsBetaAppLogs.zip" else "CommonsAppLogs.zip" + val emailSubjectFormat = if (isBeta) + BETA_LOGS_PRIVATE_EMAIL_SUBJECT + else + LOGS_PRIVATE_EMAIL_SUBJECT + emailSubject = emailSubjectFormat.format(sessionManager.userName) + emailBody = getExtraInfo() + mailTo = LOGS_PRIVATE_EMAIL + } + + /** + * Attach any extra meta information about the user or device that might help in debugging. + * @return String with extra meta information useful for debugging. + */ + public override fun getExtraInfo(): String { + return buildString { + // Getting API Level + append("API level: ") + .append(DeviceInfoUtil.getAPILevel()) + .append("\n") + + // Getting Android Version + append("Android version: ") + .append(DeviceInfoUtil.getAndroidVersion()) + .append("\n") + + // Getting Device Manufacturer + append("Device manufacturer: ") + .append(DeviceInfoUtil.getDeviceManufacturer()) + .append("\n") + + // Getting Device Model + append("Device model: ") + .append(DeviceInfoUtil.getDeviceModel()) + .append("\n") + + // Getting Device Name + append("Device: ") + .append(DeviceInfoUtil.getDevice()) + .append("\n") + + // Getting Network Type + append("Network type: ") + .append(DeviceInfoUtil.getConnectionType(context)) + .append("\n") + + // Getting App Version + append("App version name: ") + .append(context.getVersionNameWithSha()) + .append("\n") + + // Getting Username + append("User name: ") + .append(sessionManager.userName) + .append("\n") + } + } + + /** + * Determines if the log sending process requires the app to be in the foreground. + * @return False as it does not require foreground execution. + */ + override fun requiresForeground(): Boolean = false + + /** + * Sends logs to developers. Implementation can be extended. + */ + override fun send( + context: Context, + errorContent: CrashReportData, + extras: Bundle) { + // Add logic here if needed. + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.java b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.java deleted file mode 100644 index a2ebeec68..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.java +++ /dev/null @@ -1,145 +0,0 @@ -package fr.free.nrw.commons.logging; - -import android.util.Log; - -import androidx.annotation.NonNull; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Locale; -import java.util.concurrent.Executor; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.encoder.PatternLayoutEncoder; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.rolling.FixedWindowRollingPolicy; -import ch.qos.logback.core.rolling.RollingFileAppender; -import ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy; -import timber.log.Timber; - -/** - * Extends Timber's debug tree to write logs to a file - */ -public class FileLoggingTree extends Timber.DebugTree implements LogLevelSettableTree { - private final Logger logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); - private int logLevel; - private final String logFileName; - private int fileSize; - private FixedWindowRollingPolicy rollingPolicy; - private final Executor executor; - - public FileLoggingTree(int logLevel, - String logFileName, - String logDirectory, - int fileSizeInKb, - Executor executor) { - this.logLevel = logLevel; - this.logFileName = logFileName; - this.fileSize = fileSizeInKb; - configureLogger(logDirectory); - this.executor = executor; - } - - /** - * Can be overridden to change file's log level - * @param logLevel - */ - @Override - public void setLogLevel(int logLevel) { - this.logLevel = logLevel; - } - - /** - * Check and log any message - * @param priority - * @param tag - * @param message - * @param t - */ - @Override - protected void log(final int priority, final String tag, @NonNull final String message, Throwable t) { - executor.execute(() -> logMessage(priority, tag, message)); - - } - - /** - * Log any message based on the priority - * @param priority - * @param tag - * @param message - */ - private void logMessage(int priority, String tag, String message) { - String messageWithTag = String.format("[%s] : %s", tag, message); - switch (priority) { - case Log.VERBOSE: - logger.trace(messageWithTag); - break; - case Log.DEBUG: - logger.debug(messageWithTag); - break; - case Log.INFO: - logger.info(messageWithTag); - break; - case Log.WARN: - logger.warn(messageWithTag); - break; - case Log.ERROR: - logger.error(messageWithTag); - break; - case Log.ASSERT: - logger.error(messageWithTag); - break; - } - } - - /** - * Checks if a particular log line should be logged in the file or not - * @param priority - * @return - */ - @Override - protected boolean isLoggable(int priority) { - return priority >= logLevel; - } - - /** - * Configures the logger with a file size rolling policy (SizeBasedTriggeringPolicy) - * https://github.com/tony19/logback-android/wiki - * @param logDir - */ - private void configureLogger(String logDir) { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - loggerContext.reset(); - - RollingFileAppender rollingFileAppender = new RollingFileAppender<>(); - rollingFileAppender.setContext(loggerContext); - rollingFileAppender.setFile(logDir + "/" + logFileName + ".0.log"); - - rollingPolicy = new FixedWindowRollingPolicy(); - rollingPolicy.setContext(loggerContext); - rollingPolicy.setMinIndex(1); - rollingPolicy.setMaxIndex(4); - rollingPolicy.setParent(rollingFileAppender); - rollingPolicy.setFileNamePattern(logDir + "/" + logFileName + ".%i.log"); - rollingPolicy.start(); - - SizeBasedTriggeringPolicy triggeringPolicy = new SizeBasedTriggeringPolicy<>(); - triggeringPolicy.setContext(loggerContext); - triggeringPolicy.setMaxFileSize(String.format(Locale.ENGLISH, "%dKB", fileSize)); - triggeringPolicy.start(); - - PatternLayoutEncoder encoder = new PatternLayoutEncoder(); - encoder.setContext(loggerContext); - encoder.setPattern("%-27(%date{ISO8601}) [%-5level] [%thread] %msg%n"); - encoder.start(); - - rollingFileAppender.setEncoder(encoder); - rollingFileAppender.setRollingPolicy(rollingPolicy); - rollingFileAppender.setTriggeringPolicy(triggeringPolicy); - rollingFileAppender.start(); - ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) - LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); - logger.addAppender(rollingFileAppender); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt new file mode 100644 index 000000000..5c6c55f1a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt @@ -0,0 +1,133 @@ +package fr.free.nrw.commons.logging + +import android.util.Log + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import java.util.Locale +import java.util.concurrent.Executor + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.rolling.FixedWindowRollingPolicy +import ch.qos.logback.core.rolling.RollingFileAppender +import ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy +import timber.log.Timber + + +/** + * Extends Timber's debug tree to write logs to a file. + */ +class FileLoggingTree( + private var logLevel: Int, + private val logFileName: String, + logDirectory: String, + private val fileSizeInKb: Int, + private val executor: Executor +) : Timber.DebugTree(), LogLevelSettableTree { + + private val logger: Logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) + private lateinit var rollingPolicy: FixedWindowRollingPolicy + + init { + configureLogger(logDirectory) + } + + /** + * Can be overridden to change the file's log level. + * @param logLevel The new log level. + */ + override fun setLogLevel(logLevel: Int) { + this.logLevel = logLevel + } + + /** + * Checks and logs any message. + * @param priority The priority of the log message. + * @param tag The tag associated with the log message. + * @param message The log message. + * @param t An optional throwable. + */ + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + executor.execute { + logMessage(priority, tag.orEmpty(), message) + } + } + + /** + * Logs a message based on the priority. + * @param priority The priority of the log message. + * @param tag The tag associated with the log message. + * @param message The log message. + */ + private fun logMessage(priority: Int, tag: String, message: String) { + val messageWithTag = "[$tag] : $message" + when (priority) { + Log.VERBOSE -> logger.trace(messageWithTag) + Log.DEBUG -> logger.debug(messageWithTag) + Log.INFO -> logger.info(messageWithTag) + Log.WARN -> logger.warn(messageWithTag) + Log.ERROR, Log.ASSERT -> logger.error(messageWithTag) + } + } + + /** + * Checks if a particular log line should be logged in the file or not. + * @param priority The priority of the log message. + * @return True if the log message should be logged, false otherwise. + */ + @Deprecated("Deprecated in Java") + override fun isLoggable(priority: Int): Boolean { + return priority >= logLevel + } + + /** + * Configures the logger with a file size rolling policy (SizeBasedTriggeringPolicy). + * https://github.com/tony19/logback-android/wiki + * @param logDir The directory where logs should be stored. + */ + private fun configureLogger(logDir: String) { + val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext + loggerContext.reset() + + val rollingFileAppender = RollingFileAppender().apply { + context = loggerContext + file = "$logDir/$logFileName.0.log" + } + + rollingPolicy = FixedWindowRollingPolicy().apply { + context = loggerContext + minIndex = 1 + maxIndex = 4 + setParent(rollingFileAppender) + fileNamePattern = "$logDir/$logFileName.%i.log" + start() + } + + val triggeringPolicy = SizeBasedTriggeringPolicy().apply { + context = loggerContext + maxFileSize = "$fileSizeInKb" + start() + } + + val encoder = PatternLayoutEncoder().apply { + context = loggerContext + pattern = "%-27(%date{ISO8601}) [%-5level] [%thread] %msg%n" + start() + } + + rollingFileAppender.apply { + this.encoder = encoder + rollingPolicy = rollingPolicy + this.triggeringPolicy = triggeringPolicy + start() + } + + val rootLogger = LoggerFactory.getLogger( + Logger.ROOT_LOGGER_NAME + ) as ch.qos.logback.classic.Logger + rootLogger.addAppender(rollingFileAppender) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.java b/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.java deleted file mode 100644 index 5eeca6d3e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.java +++ /dev/null @@ -1,8 +0,0 @@ -package fr.free.nrw.commons.logging; - -/** - * Can be implemented to set the log level for file tree - */ -public interface LogLevelSettableTree { - void setLogLevel(int logLevel); -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.kt b/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.kt new file mode 100644 index 000000000..babe78121 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.kt @@ -0,0 +1,8 @@ +package fr.free.nrw.commons.logging + +/** + * Can be implemented to set the log level for file tree + */ +interface LogLevelSettableTree { + fun setLogLevel(logLevel: Int) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.java b/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.java deleted file mode 100644 index c28b2145b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.java +++ /dev/null @@ -1,48 +0,0 @@ -package fr.free.nrw.commons.logging; - -import android.os.Environment; - -import fr.free.nrw.commons.upload.FileUtils; -import fr.free.nrw.commons.utils.ConfigUtils; - -/** - * Returns the log directory - */ -public final class LogUtils { - private LogUtils() { - } - - /** - * Returns the directory for saving logs on the device - * - * @return - */ - public static String getLogDirectory() { - String dirPath; - if (ConfigUtils.isBetaFlavour()) { - dirPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/beta"; - } else { - dirPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/prod"; - } - - FileUtils.recursivelyCreateDirs(dirPath); - return dirPath; - } - - /** - * Returns the directory for saving logs on the device - * - * @return - */ - public static String getLogZipDirectory() { - String dirPath; - if (ConfigUtils.isBetaFlavour()) { - dirPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/beta/zip"; - } else { - dirPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/prod/zip"; - } - - FileUtils.recursivelyCreateDirs(dirPath); - return dirPath; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.kt b/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.kt new file mode 100644 index 000000000..6c91d92dd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.kt @@ -0,0 +1,57 @@ +package fr.free.nrw.commons.logging + +import android.os.Environment + +import fr.free.nrw.commons.upload.FileUtils +import fr.free.nrw.commons.utils.ConfigUtils + + +/** + * Returns the log directory + */ +object LogUtils { + + /** + * Returns the directory for saving logs on the device. + * + * @return The path to the log directory. + */ + fun getLogDirectory(): String { + val dirPath = if (ConfigUtils.isBetaFlavour) { + "${Environment + .getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + )}/logs/beta" + } else { + "${Environment + .getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + )}/logs/prod" + } + + FileUtils.recursivelyCreateDirs(dirPath) + return dirPath + } + + /** + * Returns the directory for saving zipped logs on the device. + * + * @return The path to the zipped log directory. + */ + fun getLogZipDirectory(): String { + val dirPath = if (ConfigUtils.isBetaFlavour) { + "${Environment + .getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + )}/logs/beta/zip" + } else { + "${Environment + .getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + )}/logs/prod/zip" + } + + FileUtils.recursivelyCreateDirs(dirPath) + return dirPath + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.java b/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.java deleted file mode 100644 index 68f7bd78c..000000000 --- a/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.java +++ /dev/null @@ -1,201 +0,0 @@ -package fr.free.nrw.commons.logging; - -import static org.acra.ACRA.getErrorReporter; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.FileProvider; - -import org.acra.data.CrashReportData; -import org.acra.sender.ReportSender; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.charset.Charset; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.SessionManager; -import timber.log.Timber; - -/** - * Abstract class that implements Acra's log sender - */ -public abstract class LogsSender implements ReportSender { - - String mailTo; - String logFileName; - String emailSubject; - String emailBody; - - private final SessionManager sessionManager; - - LogsSender(SessionManager sessionManager) { - this.sessionManager = sessionManager; - } - - /** - * Overrides send method of ACRA's ReportSender to send logs - * - * @param context - * @param report - */ - @Override - public void send(@NonNull final Context context, @Nullable CrashReportData report) { - sendLogs(context, report); - } - - /** - * Gets zipped log files and sends it via email. Can be modified to change the send log mechanism - * - * @param context - * @param report - */ - private void sendLogs(Context context, CrashReportData report) { - final Uri logFileUri = getZippedLogFileUri(context, report); - if (logFileUri != null) { - sendEmail(context, logFileUri); - } else { - getErrorReporter().handleSilentException(null); - } - } - - /*** - * Provides any extra information that you want to send. The return value will be - * delivered inside the report verbatim - * - * @return - */ - protected abstract String getExtraInfo(); - - /** - * Fires an intent to send email with logs - * - * @param context - * @param logFileUri - */ - private void sendEmail(Context context, Uri logFileUri) { - String subject = emailSubject; - String body = emailBody; - - Intent emailIntent = new Intent(Intent.ACTION_SEND); - emailIntent.setType("message/rfc822"); - emailIntent.putExtra(Intent.EXTRA_EMAIL, new String[]{mailTo}); - emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject); - emailIntent.putExtra(Intent.EXTRA_TEXT, body); - emailIntent.putExtra(Intent.EXTRA_STREAM, logFileUri); - emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - emailIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - context.startActivity(Intent.createChooser(emailIntent, context.getString(R.string.share_logs_using))); - } - - /** - * Returns the URI for the zipped log file - * - * @param report - * @return - */ - private Uri getZippedLogFileUri(Context context, CrashReportData report) { - try { - StringBuilder builder = new StringBuilder(); - if (report != null) { - attachCrashInfo(report, builder); - } - attachUserInfo(builder); - attachExtraInfo(builder); - byte[] metaData = builder.toString().getBytes(Charset.forName("UTF-8")); - File zipFile = new File(LogUtils.getLogZipDirectory(), logFileName); - writeLogToZipFile(metaData, zipFile); - return FileProvider - .getUriForFile(context, - context.getApplicationContext().getPackageName() + ".provider", zipFile); - } catch (IOException e) { - Timber.w(e, "Error in generating log file"); - } - return null; - } - - /** - * Checks if there are any pending crash reports and attaches them to the logs - * - * @param report - * @param builder - */ - private void attachCrashInfo(CrashReportData report, StringBuilder builder) { - if (report == null) { - return; - } - builder.append(report); - } - - /** - * Attaches username to the the meta_data file - * - * @param builder - */ - private void attachUserInfo(StringBuilder builder) { - builder.append("MediaWiki Username = ").append(sessionManager.getUserName()).append("\n"); - } - - /** - * Gets any extra meta information to be attached with the log files - * - * @param builder - */ - private void attachExtraInfo(StringBuilder builder) { - String infoToBeAttached = getExtraInfo(); - builder.append(infoToBeAttached); - builder.append("\n"); - } - - /** - * Zips the logs and meta information - * - * @param metaData - * @param zipFile - * @throws IOException - */ - private void writeLogToZipFile(byte[] metaData, File zipFile) throws IOException { - FileOutputStream fos = new FileOutputStream(zipFile); - BufferedOutputStream bos = new BufferedOutputStream(fos); - ZipOutputStream zos = new ZipOutputStream(bos); - File logDir = new File(LogUtils.getLogDirectory()); - - if (!logDir.exists() || logDir.listFiles().length == 0) { - return; - } - - byte[] buffer = new byte[1024]; - for (File file : logDir.listFiles()) { - if (file.isDirectory()) { - continue; - } - FileInputStream fis = new FileInputStream(file); - BufferedInputStream bis = new BufferedInputStream(fis); - zos.putNextEntry(new ZipEntry(file.getName())); - int length; - while ((length = bis.read(buffer)) > 0) { - zos.write(buffer, 0, length); - } - zos.closeEntry(); - bis.close(); - } - - //attach metadata as a separate file - zos.putNextEntry(new ZipEntry("meta_data.txt")); - zos.write(metaData); - zos.closeEntry(); - - zos.flush(); - zos.close(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.kt b/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.kt new file mode 100644 index 000000000..cd6bb7d70 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.kt @@ -0,0 +1,193 @@ +package fr.free.nrw.commons.logging + +import android.content.Context +import android.content.Intent +import android.net.Uri + +import androidx.core.content.FileProvider + +import org.acra.data.CrashReportData +import org.acra.sender.ReportSender + +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import org.acra.ACRA.errorReporter +import timber.log.Timber + + +/** + * Abstract class that implements Acra's log sender. + */ +abstract class LogsSender( + private val sessionManager: SessionManager +): ReportSender { + + var mailTo: String? = null + var logFileName: String? = null + var emailSubject: String? = null + var emailBody: String? = null + + /** + * Overrides the send method of ACRA's ReportSender to send logs. + * + * @param context The context in which to send the logs. + * @param report The crash report data, if any. + */ + fun sendWithNullable(context: Context, report: CrashReportData?) { + if (report == null) { + errorReporter.handleSilentException(null) + return + } + send(context, report) + } + + override fun send(context: Context, report: CrashReportData) { + sendLogs(context, report) + } + + /** + * Gets zipped log files and sends them via email. Can be modified to change the send + * log mechanism. + * + * @param context The context in which to send the logs. + * @param report The crash report data, if any. + */ + private fun sendLogs(context: Context, report: CrashReportData?) { + val logFileUri = getZippedLogFileUri(context, report) + if (logFileUri != null) { + sendEmail(context, logFileUri) + } else { + errorReporter.handleSilentException(null) + + } + } + + /** + * Provides any extra information that you want to send. The return value will be + * delivered inside the report verbatim. + * + * @return A string containing the extra information. + */ + protected abstract fun getExtraInfo(): String + + /** + * Fires an intent to send an email with logs. + * + * @param context The context in which to send the email. + * @param logFileUri The URI of the zipped log file. + */ + private fun sendEmail(context: Context, logFileUri: Uri) { + val emailIntent = Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + putExtra(Intent.EXTRA_EMAIL, arrayOf(mailTo)) + putExtra(Intent.EXTRA_SUBJECT, emailSubject) + putExtra(Intent.EXTRA_TEXT, emailBody) + putExtra(Intent.EXTRA_STREAM, logFileUri) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(emailIntent, context.getString(R.string.share_logs_using))) + } + + /** + * Returns the URI for the zipped log file. + * + * @param context The context for file URI generation. + * @param report The crash report data, if any. + * @return The URI of the zipped log file or null if an error occurs. + */ + private fun getZippedLogFileUri(context: Context, report: CrashReportData?): Uri? { + return try { + val builder = StringBuilder().apply { + report?.let { attachCrashInfo(it, this) } + attachUserInfo(this) + attachExtraInfo(this) + } + val metaData = builder.toString().toByteArray(Charsets.UTF_8) + val zipFile = File(LogUtils.getLogZipDirectory(), logFileName ?: "logs.zip") + writeLogToZipFile(metaData, zipFile) + FileProvider.getUriForFile( + context, + "${context.applicationContext.packageName}.provider", + zipFile + ) + } catch (e: IOException) { + Timber.w(e, "Error in generating log file") + null + } + } + + /** + * Checks if there are any pending crash reports and attaches them to the logs. + * + * @param report The crash report data, if any. + * @param builder The string builder to append crash info. + */ + private fun attachCrashInfo(report: CrashReportData?, builder: StringBuilder) { + if(report != null) { + builder.append(report) + } + } + + /** + * Attaches the username to the metadata file. + * + * @param builder The string builder to append user info. + */ + private fun attachUserInfo(builder: StringBuilder) { + builder.append("MediaWiki Username = ").append(sessionManager.userName).append("\n") + } + + /** + * Gets any extra metadata information to be attached with the log files. + * + * @param builder The string builder to append extra info. + */ + private fun attachExtraInfo(builder: StringBuilder) { + builder.append(getExtraInfo()).append("\n") + } + + /** + * Zips the logs and metadata information. + * + * @param metaData The metadata to be added to the zip file. + * @param zipFile The zip file to write to. + * @throws IOException If an I/O error occurs. + */ + @Throws(IOException::class) + private fun writeLogToZipFile(metaData: ByteArray, zipFile: File) { + val logDir = File(LogUtils.getLogDirectory()) + if (!logDir.exists() || logDir.listFiles().isNullOrEmpty()) return + + ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos -> + val buffer = ByteArray(1024) + logDir.listFiles()?.forEach { file -> + if (file.isDirectory) return@forEach + FileInputStream(file).use { fis -> + BufferedInputStream(fis).use { bis -> + zos.putNextEntry(ZipEntry(file.name)) + var length: Int + while (bis.read(buffer).also { length = it } > 0) { + zos.write(buffer, 0, length) + } + zos.closeEntry() + } + } + } + + // Attach metadata as a separate file. + zos.putNextEntry(ZipEntry("meta_data.txt")) + zos.write(metaData) + zos.closeEntry() + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt index b55ac6009..86ee5c4fe 100644 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt @@ -1,6 +1,7 @@ package fr.free.nrw.commons.settings import android.Manifest.permission +import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog import android.content.Context.MODE_PRIVATE @@ -527,7 +528,7 @@ class SettingsFragment : PreferenceFragmentCompat() { PermissionUtils.PERMISSIONS_STORAGE ) ) { - commonsLogSender.send(requireActivity(), null) + commonsLogSender.sendWithNullable(requireActivity(), null) } else { requestExternalStoragePermissions() } diff --git a/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.kt index f4b1f2625..87a710424 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/SystemThemeUtils.kt @@ -46,7 +46,7 @@ class SystemThemeUtils @Inject constructor( // Returns true if the device is in night mode or false otherwise fun isDeviceInNightMode(): Boolean { return getSystemDefaultThemeBool( - applicationKvStore.getString(Prefs.KEY_THEME_VALUE, getSystemDefaultTheme()) + applicationKvStore.getString(Prefs.KEY_THEME_VALUE, getSystemDefaultTheme())!! ) } } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4e90f6864..f02ea7dac 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -29,6 +29,7 @@ * Jduranboger * Jelou * Johnny243 +* Josuert * Juanman * Keneth Urrutia * Ktranz @@ -166,6 +167,7 @@ Buscar categorías Buscar elementos que tu archivo multimedia representa (montaña, Taj Mahal, etc.) Guardar + Menú de desbordamiento Actualizar Lista (No hay subidas aún) @@ -522,6 +524,7 @@ No tienes notificaciones sin leer No tienes ninguna notificación leída Compartir registros usando + Revisa tu bandeja de entrada Ver leídas Ver no leidas Ocurrió un error mientras se elegían imagenes @@ -819,8 +822,26 @@ Por favor, escriba algunos comentarios. Discusión Escriba algo sobre el elemento \'%1$s\'. Será visible públicamente. + \' %1$s \' ya no existe, nunca se podrá tomar ninguna fotografía de él. + \' %1$s \' está en un lugar diferente. Especifique el lugar correcto a continuación y, si es posible, escriba la latitud y longitud correctas. + Otro problema o información (por favor explique a continuación). + Sus comentarios se publicarán en la siguiente página wiki: <a href=\"https://commons.wikimedia.org/wiki/Commons:Mobile_app/Feedback\">Commons:Mobile_app/Feedback</a> + ¿Estás seguro de que deseas cancelar todas las subidas? Cancelando todas las subidas... Subidas Pendiente Falló + No se pudieron cargar los datos del lugar + Eliminar carpeta + Confirmar eliminación + ¿Está seguro de que deseas eliminar la carpeta %1$s que contiene %2$d elementos? + Eliminar + Cancelar + La carpeta %1$s se eliminó correctamente + No se pudo eliminar la carpeta %1$s + Error al eliminar el contenido de la carpeta: %1$s + No se pudo recuperar la ruta de la carpeta para el ID del bucket: %1$d + Este lugar aún no tiene foto, ¡ve y toma una! + Este lugar ya tiene una foto. + Ahora comprobando si este lugar tiene una foto. diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index be45099a9..a77bdfea9 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -542,9 +542,9 @@ %1$s הועלה על ידי: %2$s שפת התיאור כבררת מחדל העמדה למחיקה - הצלחה + זה עבד הקובץ %1$s הועמד למחיקה. - כשלון + זה לא עבד לא ניתן לבקש מחיקה תמונה עצמית (סלפי) שלא משמשת בשום ערך תמונה מטושטשת לגמרי @@ -825,7 +825,7 @@ מחיקת תיקייה אישור מחיקה למחוק את התיקייה %1$s על כל %2$d פריטיה? - מחיקה + למחוק ביטול התיקייה %1$s נמחקה מחיקת התיקייה %1$s נכשלה diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index e06969dc2..833743aef 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -204,4 +204,5 @@ {{Identical|Detail}} \"Set as avatar\" should be translated the same as {{msg-wm|Commons-android-strings-menu set avatar}}. {{Doc-commons-app-depicts}} + An answer to the question in {{msg-wm|Commons-android-strings-custom selector confirm deletion message}}. diff --git a/app/src/test/kotlin/fr/free/nrw/commons/campaigns/CampaignsPresenterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/campaigns/CampaignsPresenterTest.kt index 7efdfd1ad..ec3ad82f1 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/campaigns/CampaignsPresenterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/campaigns/CampaignsPresenterTest.kt @@ -20,25 +20,24 @@ import kotlin.collections.ArrayList class CampaignsPresenterTest { @Mock - lateinit var okHttpJsonApiClient: OkHttpJsonApiClient - - lateinit var campaignsPresenter: CampaignsPresenter + private lateinit var okHttpJsonApiClient: OkHttpJsonApiClient @Mock - internal lateinit var view: ICampaignsView + private lateinit var view: ICampaignsView @Mock - internal lateinit var campaignResponseDTO: CampaignResponseDTO - lateinit var campaignsSingle: Single + private lateinit var campaignResponseDTO: CampaignResponseDTO @Mock - lateinit var campaign: Campaign - - lateinit var testScheduler: TestScheduler + private lateinit var campaign: Campaign @Mock private lateinit var disposable: Disposable + private lateinit var campaignsPresenter: CampaignsPresenter + private lateinit var campaignsSingle: Single + private lateinit var testScheduler: TestScheduler + /** * initial setup, test environment */ diff --git a/app/src/test/kotlin/fr/free/nrw/commons/kvstore/BasicKvStoreTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/kvstore/BasicKvStoreTest.kt new file mode 100644 index 000000000..99fdf915b --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/kvstore/BasicKvStoreTest.kt @@ -0,0 +1,238 @@ +package fr.free.nrw.commons.kvstore + +import android.content.Context +import android.content.SharedPreferences +import com.nhaarman.mockitokotlin2.atLeast +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import fr.free.nrw.commons.kvstore.BasicKvStore.Companion.KEY_VERSION +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.mock + +class BasicKvStoreTest { + private val context = mock() + private val prefs = mock() + private val editor = mock() + private lateinit var store: BasicKvStore + + @Before + fun setUp() { + whenever(context.getSharedPreferences(anyString(), anyInt())).thenReturn(prefs) + whenever(prefs.edit()).thenReturn(editor) + store = BasicKvStore(context, "name") + } + + @Test + fun versionUpdate() { + whenever(prefs.getInt(KEY_VERSION, 0)).thenReturn(99) + BasicKvStore(context, "name", 100, true) + + // It should clear itself and automatically put the new version number + verify(prefs, atLeast(2)).edit() + verify(editor).clear() + verify(editor).putInt(KEY_VERSION, 100) + verify(editor, atLeast(2)).apply() + } + + @Test(expected = IllegalArgumentException::class) + fun versionDowngradeNotAllowed() { + whenever(prefs.getInt(KEY_VERSION, 0)).thenReturn(100) + BasicKvStore(context, "name", 99, true) + } + + @Test + fun versionRedactedFromGetAll() { + val all = mutableMapOf("key" to "value", KEY_VERSION to 100) + whenever(prefs.all).thenReturn(all) + + val result = store.all + Assert.assertEquals(mapOf("key" to "value"), result) + } + + @Test + fun getAllHandlesNull() { + whenever(prefs.all).thenReturn(null) + Assert.assertNull(store.all) + } + + @Test + fun getAllHandlesEmpty() { + whenever(prefs.all).thenReturn(emptyMap()) + Assert.assertNull(store.all) + } + + @Test + fun getString() { + whenever(prefs.getString("key", null)).thenReturn("value") + Assert.assertEquals("value", store.getString("key")) + } + + @Test + fun getBoolean() { + whenever(prefs.getBoolean("key", false)).thenReturn(true) + Assert.assertTrue(store.getBoolean("key")) + } + + @Test + fun getLong() { + whenever(prefs.getLong("key", 0L)).thenReturn(100) + Assert.assertEquals(100L, store.getLong("key")) + } + + @Test + fun getInt() { + whenever(prefs.getInt("key", 0)).thenReturn(100) + Assert.assertEquals(100, store.getInt("key")) + } + + @Test + fun getStringWithDefault() { + whenever(prefs.getString("key", "junk")).thenReturn("value") + Assert.assertEquals("value", store.getString("key", "junk")) + } + + @Test + fun getBooleanWithDefault() { + whenever(prefs.getBoolean("key", true)).thenReturn(true) + Assert.assertTrue(store.getBoolean("key", true)) + } + + @Test + fun getLongWithDefault() { + whenever(prefs.getLong("key", 22L)).thenReturn(100) + Assert.assertEquals(100L, store.getLong("key", 22L)) + } + + @Test + fun getIntWithDefault() { + whenever(prefs.getInt("key", 22)).thenReturn(100) + Assert.assertEquals(100, store.getInt("key", 22)) + } + + @Test + fun putAllStrings() { + store.putAllStrings( + mapOf( + "one" to "fish", + "two" to "fish", + "red" to "fish", + "blue" to "fish" + ) + ) + + verify(prefs).edit() + verify(editor).putString("one", "fish") + verify(editor).putString("two", "fish") + verify(editor).putString("red", "fish") + verify(editor).putString("blue", "fish") + verify(editor).apply() + } + + @Test(expected = IllegalArgumentException::class) + fun putAllStringsWithReservedKey() { + store.putAllStrings( + mapOf( + "this" to "that", + KEY_VERSION to "something" + ) + ) + } + + @Test + fun putString() { + store.putString("this" , "that") + + verify(prefs).edit() + verify(editor).putString("this", "that") + verify(editor).apply() + } + + @Test + fun putBoolean() { + store.putBoolean("this" , true) + + verify(prefs).edit() + verify(editor).putBoolean("this", true) + verify(editor).apply() + } + + @Test + fun putLong() { + store.putLong("this" , 123L) + + verify(prefs).edit() + verify(editor).putLong("this", 123L) + verify(editor).apply() + } + + @Test + fun putInt() { + store.putInt("this" , 16) + + verify(prefs).edit() + verify(editor).putInt("this", 16) + verify(editor).apply() + } + + @Test(expected = IllegalArgumentException::class) + fun putStringWithReservedKey() { + store.putString(KEY_VERSION, "that") + } + + @Test(expected = IllegalArgumentException::class) + fun putBooleanWithReservedKey() { + store.putBoolean(KEY_VERSION, true) + } + + @Test(expected = IllegalArgumentException::class) + fun putLongWithReservedKey() { + store.putLong(KEY_VERSION, 33L) + } + + @Test(expected = IllegalArgumentException::class) + fun putIntWithReservedKey() { + store.putInt(KEY_VERSION, 12) + } + + @Test + fun testContains() { + whenever(prefs.contains("key")).thenReturn(true) + Assert.assertTrue(store.contains("key")) + } + + @Test + fun containsRedactsVersion() { + whenever(prefs.contains(KEY_VERSION)).thenReturn(true) + Assert.assertFalse(store.contains(KEY_VERSION)) + } + + @Test + fun remove() { + store.remove("key") + + verify(prefs).edit() + verify(editor).remove("key") + verify(editor).apply() + } + + @Test(expected = IllegalArgumentException::class) + fun removeWithReservedKey() { + store.remove(KEY_VERSION) + } + + @Test + fun clearAllPreservesVersion() { + whenever(prefs.getInt(KEY_VERSION, 0)).thenReturn(99) + + store.clearAll() + + verify(prefs).edit() + verify(editor).clear() + verify(editor).putInt(KEY_VERSION, 99) + verify(editor).apply() + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/kvstore/JsonKvStoreTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/kvstore/JsonKvStoreTest.kt new file mode 100644 index 000000000..0a0bdfc47 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/kvstore/JsonKvStoreTest.kt @@ -0,0 +1,85 @@ +package fr.free.nrw.commons.kvstore + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.Gson +import com.nhaarman.mockitokotlin2.atLeast +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import fr.free.nrw.commons.kvstore.BasicKvStore.Companion.KEY_VERSION +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.mock + +class JsonKvStoreTest { + private val context = mock() + private val prefs = mock() + private val editor = mock() + + private val gson = Gson() + private val testData = Person(16, "Bob", true, Pet("Poodle", 2)) + private val expected = gson.toJson(testData) + + private lateinit var store: JsonKvStore + + @Before + fun setUp() { + whenever(context.getSharedPreferences(anyString(), anyInt())).thenReturn(prefs) + whenever(prefs.edit()).thenReturn(editor) + store = JsonKvStore(context, "name", gson) + } + + @Test + fun putJson() { + store.putJson("person", testData) + + verify(prefs).edit() + verify(editor).putString("person", expected) + verify(editor).apply() + } + + @Test(expected = IllegalArgumentException::class) + fun putJsonWithReservedKey() { + store.putJson(KEY_VERSION, testData) + } + + @Test + fun getJson() { + whenever(prefs.getString("key", null)).thenReturn(expected) + + val result = store.getJson("key", Person::class.java) + + Assert.assertEquals(testData, result) + } + + @Test + fun getJsonInTheFuture() { + whenever(prefs.getString("key", null)).thenReturn(expected) + + val resultOne: Person? = store.getJson("key") + Assert.assertEquals(testData, resultOne) + + val resultTwo = store.getJson("key") + Assert.assertEquals(testData, resultTwo) + } + + @Test + fun getJsonHandlesMalformedJson() { + whenever(prefs.getString("key", null)).thenReturn("junk") + + val result = store.getJson("key", Person::class.java) + + Assert.assertNull(result) + } + + data class Person( + val age: Int, val name: String, val hasPets: Boolean, val pet: Pet? + ) + + data class Pet( + val breed: String, val age: Int + ) +} \ No newline at end of file