mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-29 13:53:54 +01:00
Merge branch 'main' into bookmark
This commit is contained in:
commit
4daa3d6094
31 changed files with 1334 additions and 1092 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,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<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,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<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.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<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,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);
|
||||
}
|
||||
|
|
@ -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?)
|
||||
}
|
||||
|
|
@ -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<String> getKeySet() {
|
||||
Map<String, ?> allContents = new HashMap<>(_store.getAll());
|
||||
allContents.remove(KEY_VERSION);
|
||||
return allContents.keySet();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Map<String, ?> getAll() {
|
||||
Map<String, ?> 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<String, String> keyValuePairs) {
|
||||
SharedPreferences.Editor editor = _store.edit();
|
||||
for (Map.Entry<String, String> 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<String> getStringSet(String key){
|
||||
return _store.getStringSet(key, new HashSet<>());
|
||||
}
|
||||
|
||||
public void putStringSet(String key,Set<String> value){
|
||||
_store.edit().putStringSet(key,value).apply();
|
||||
}
|
||||
}
|
||||
152
app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.kt
Normal file
152
app/src/main/java/fr/free/nrw/commons/kvstore/BasicKvStore.kt
Normal file
|
|
@ -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<String, *>?
|
||||
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<String> =
|
||||
_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<String, String>) = 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<String?>?) =
|
||||
_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<String>, 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__"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <T> void putAllJsons(Map<String, T> jsonMap) {
|
||||
Map<String, String> stringsMap = new HashMap<>(jsonMap.size());
|
||||
for (Map.Entry<String, T> keyValuePair : jsonMap.entrySet()) {
|
||||
String jsonString = gson.toJson(keyValuePair.getValue());
|
||||
stringsMap.put(keyValuePair.getKey(), jsonString);
|
||||
}
|
||||
putAllStrings(stringsMap);
|
||||
}
|
||||
|
||||
public <T> void putJson(String key, T object) {
|
||||
putString(key, gson.toJson(object));
|
||||
}
|
||||
|
||||
public <T> void putJsonWithTypeInfo(String key, T object, Type type) {
|
||||
putString(key, gson.toJson(object, type));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public <T> T getJson(String key, Class<T> clazz) {
|
||||
String jsonString = getString(key);
|
||||
try {
|
||||
return gson.fromJson(jsonString, clazz);
|
||||
} catch (JsonSyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public <T> T getJson(String key, Type type) {
|
||||
String jsonString = getString(key);
|
||||
try {
|
||||
return gson.fromJson(jsonString, type);
|
||||
} catch (JsonSyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
52
app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.kt
Normal file
52
app/src/main/java/fr/free/nrw/commons/kvstore/JsonKvStore.kt
Normal file
|
|
@ -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 <T> putJson(key: String, value: T) = assertKeyNotReserved(key) {
|
||||
putString(key, gson.toJson(value))
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
message = "Migrate to newer Kotlin syntax",
|
||||
replaceWith = ReplaceWith("getJson<T>(key)")
|
||||
)
|
||||
fun <T> getJson(key: String, clazz: Class<T>?): 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 <reified T> getJson(key: String): T? = try {
|
||||
gson.fromJson(getString(key), T::class.java)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
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();
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ILoggingEvent> 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<ILoggingEvent> 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);
|
||||
}
|
||||
}
|
||||
133
app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt
Normal file
133
app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt
Normal file
|
|
@ -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<ILoggingEvent>().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<ILoggingEvent>().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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
57
app/src/main/java/fr/free/nrw/commons/logging/LogUtils.kt
Normal file
57
app/src/main/java/fr/free/nrw/commons/logging/LogUtils.kt
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
193
app/src/main/java/fr/free/nrw/commons/logging/LogsSender.kt
Normal file
193
app/src/main/java/fr/free/nrw/commons/logging/LogsSender.kt
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
* Jduranboger
|
||||
* Jelou
|
||||
* Johnny243
|
||||
* Josuert
|
||||
* Juanman
|
||||
* Keneth Urrutia
|
||||
* Ktranz
|
||||
|
|
@ -166,6 +167,7 @@
|
|||
<string name="categories_search_text_hint">Buscar categorías</string>
|
||||
<string name="depicts_search_text_hint">Buscar elementos que tu archivo multimedia representa (montaña, Taj Mahal, etc.)</string>
|
||||
<string name="menu_save_categories">Guardar</string>
|
||||
<string name="menu_overflow_desc">Menú de desbordamiento</string>
|
||||
<string name="refresh_button">Actualizar</string>
|
||||
<string name="display_list_button">Lista</string>
|
||||
<string name="contributions_subtitle_zero">(No hay subidas aún)</string>
|
||||
|
|
@ -522,6 +524,7 @@
|
|||
<string name="no_notification">No tienes notificaciones sin leer</string>
|
||||
<string name="no_read_notification">No tienes ninguna notificación leída</string>
|
||||
<string name="share_logs_using">Compartir registros usando</string>
|
||||
<string name="check_your_email_inbox">Revisa tu bandeja de entrada</string>
|
||||
<string name="menu_option_read">Ver leídas</string>
|
||||
<string name="menu_option_unread">Ver no leidas</string>
|
||||
<string name="error_occurred_in_picking_images">Ocurrió un error mientras se elegían imagenes</string>
|
||||
|
|
@ -819,8 +822,26 @@
|
|||
<string name="please_enter_some_comments">Por favor, escriba algunos comentarios.</string>
|
||||
<string name="talk">Discusión</string>
|
||||
<string name="write_something_about_the_item">Escriba algo sobre el elemento \'%1$s\'. Será visible públicamente.</string>
|
||||
<string name="does_not_exist_anymore_no_picture_can_ever_be_taken_of_it">\' %1$s \' ya no existe, nunca se podrá tomar ninguna fotografía de él.</string>
|
||||
<string name="is_at_a_different_place_please_specify_the_correct_place_below_if_possible_tell_us_the_correct_latitude_longitude">\' %1$s \' está en un lugar diferente. Especifique el lugar correcto a continuación y, si es posible, escriba la latitud y longitud correctas.</string>
|
||||
<string name="other_problem_or_information_please_explain_below">Otro problema o información (por favor explique a continuación).</string>
|
||||
<string name="feedback_destination_note">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></string>
|
||||
<string name="are_you_sure_that_you_want_cancel_all_the_uploads">¿Estás seguro de que deseas cancelar todas las subidas?</string>
|
||||
<string name="cancelling_all_the_uploads">Cancelando todas las subidas...</string>
|
||||
<string name="uploads">Subidas</string>
|
||||
<string name="pending">Pendiente</string>
|
||||
<string name="failed">Falló</string>
|
||||
<string name="could_not_load_place_data">No se pudieron cargar los datos del lugar</string>
|
||||
<string name="custom_selector_delete_folder">Eliminar carpeta</string>
|
||||
<string name="custom_selector_confirm_deletion_title">Confirmar eliminación</string>
|
||||
<string name="custom_selector_confirm_deletion_message">¿Está seguro de que deseas eliminar la carpeta %1$s que contiene %2$d elementos?</string>
|
||||
<string name="custom_selector_delete">Eliminar</string>
|
||||
<string name="custom_selector_cancel">Cancelar</string>
|
||||
<string name="custom_selector_folder_deleted_success">La carpeta %1$s se eliminó correctamente</string>
|
||||
<string name="custom_selector_folder_deleted_failure">No se pudo eliminar la carpeta %1$s</string>
|
||||
<string name="custom_selector_error_trashing_folder_contents">Error al eliminar el contenido de la carpeta: %1$s</string>
|
||||
<string name="custom_selector_folder_not_found_error">No se pudo recuperar la ruta de la carpeta para el ID del bucket: %1$d</string>
|
||||
<string name="red_pin">Este lugar aún no tiene foto, ¡ve y toma una!</string>
|
||||
<string name="green_pin">Este lugar ya tiene una foto.</string>
|
||||
<string name="grey_pin">Ahora comprobando si este lugar tiene una foto.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -542,9 +542,9 @@
|
|||
<string name="review_is_uploaded_by">%1$s הועלה על ידי: %2$s</string>
|
||||
<string name="default_description_language">שפת התיאור כבררת מחדל</string>
|
||||
<string name="delete_helper_show_deletion_title">העמדה למחיקה</string>
|
||||
<string name="delete_helper_show_deletion_title_success">הצלחה</string>
|
||||
<string name="delete_helper_show_deletion_title_success">זה עבד</string>
|
||||
<string name="delete_helper_show_deletion_message_if">הקובץ %1$s הועמד למחיקה.</string>
|
||||
<string name="delete_helper_show_deletion_title_failed">כשלון</string>
|
||||
<string name="delete_helper_show_deletion_title_failed">זה לא עבד</string>
|
||||
<string name="delete_helper_show_deletion_message_else">לא ניתן לבקש מחיקה</string>
|
||||
<string name="delete_helper_ask_spam_selfie">תמונה עצמית (סלפי) שלא משמשת בשום ערך</string>
|
||||
<string name="delete_helper_ask_spam_blurry">תמונה מטושטשת לגמרי</string>
|
||||
|
|
@ -825,7 +825,7 @@
|
|||
<string name="custom_selector_delete_folder">מחיקת תיקייה</string>
|
||||
<string name="custom_selector_confirm_deletion_title">אישור מחיקה</string>
|
||||
<string name="custom_selector_confirm_deletion_message">למחוק את התיקייה %1$s על כל %2$d פריטיה?</string>
|
||||
<string name="custom_selector_delete">מחיקה</string>
|
||||
<string name="custom_selector_delete">למחוק</string>
|
||||
<string name="custom_selector_cancel">ביטול</string>
|
||||
<string name="custom_selector_folder_deleted_success">התיקייה %1$s נמחקה</string>
|
||||
<string name="custom_selector_folder_deleted_failure">מחיקת התיקייה %1$s נכשלה</string>
|
||||
|
|
|
|||
|
|
@ -204,4 +204,5 @@
|
|||
<string name="explore_map_details">{{Identical|Detail}}</string>
|
||||
<string name="set_up_avatar_toast_string">\"Set as avatar\" should be translated the same as {{msg-wm|Commons-android-strings-menu set avatar}}.</string>
|
||||
<string name="multiple_files_depiction">{{Doc-commons-app-depicts}}</string>
|
||||
<string name="custom_selector_delete">An answer to the question in {{msg-wm|Commons-android-strings-custom selector confirm deletion message}}.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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<CampaignResponseDTO>
|
||||
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<CampaignResponseDTO>
|
||||
private lateinit var testScheduler: TestScheduler
|
||||
|
||||
/**
|
||||
* initial setup, test environment
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<Context>()
|
||||
private val prefs = mock<SharedPreferences>()
|
||||
private val editor = mock<SharedPreferences.Editor>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Context>()
|
||||
private val prefs = mock<SharedPreferences>()
|
||||
private val editor = mock<SharedPreferences.Editor>()
|
||||
|
||||
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<Person?>("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
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue