mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-31 14:53:59 +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
	
	 Neel Doshi
						Neel Doshi