Merge branch 'main' into bookmark

This commit is contained in:
Neel Doshi 2024-11-30 03:52:57 +05:30 committed by GitHub
commit 4daa3d6094
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1334 additions and 1092 deletions

View file

@ -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(

View file

@ -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();
}
}
}
}
}

View 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"
}
}

View file

@ -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");
}
});
}
}
}

View file

@ -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
}
}
}

View file

@ -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);
}

View file

@ -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?)
}

View file

@ -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();
}
}

View 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__"
}
}

View file

@ -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;
}
}
}

View 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
}
}

View file

@ -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();
}

View file

@ -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()
}

View file

@ -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 {
}
}

View file

@ -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.
}
}

View file

@ -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);
}
}

View 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)
}
}

View file

@ -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);
}

View file

@ -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)
}

View file

@ -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;
}
}

View 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
}
}

View file

@ -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();
}
}

View 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()
}
}
}

View file

@ -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()
}

View file

@ -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())!!
)
}
}

View file

@ -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: &lt;a href=\"https://commons.wikimedia.org/wiki/Commons:Mobile_app/Feedback\"&gt;Commons:Mobile_app/Feedback&lt;/a&gt;</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>

View file

@ -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>

View file

@ -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>

View file

@ -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
*/

View file

@ -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()
}
}

View file

@ -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
)
}