mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 00:29:11 +01:00
Move all files to natural position.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorSet;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
final class InsightsAnimatorSetFactory {
|
||||
private static final int PROGRESS_ANIMATION_DURATION = 800;
|
||||
private static final int DETAILS_ANIMATION_DURATION = 200;
|
||||
private static final int PERCENT_SECURE_ANIMATION_DURATION = 400;
|
||||
private static final int LOTTIE_ANIMATION_DURATION = 1500;
|
||||
private static final int ANIMATION_START_DELAY = PROGRESS_ANIMATION_DURATION - DETAILS_ANIMATION_DURATION;
|
||||
private static final float PERCENT_SECURE_MAX_SCALE = 1.3f;
|
||||
|
||||
private InsightsAnimatorSetFactory() {
|
||||
}
|
||||
|
||||
static AnimatorSet create(int insecurePercent,
|
||||
@Nullable final UpdateListener progressUpdateListener,
|
||||
@Nullable final UpdateListener detailsUpdateListener,
|
||||
@Nullable final UpdateListener percentSecureListener,
|
||||
@Nullable final UpdateListener lottieListener)
|
||||
{
|
||||
final int securePercent = 100 - insecurePercent;
|
||||
final AnimatorSet animatorSet = new AnimatorSet();
|
||||
final ValueAnimator[] animators = Stream.of(createProgressAnimator(securePercent, progressUpdateListener),
|
||||
createDetailsAnimator(detailsUpdateListener),
|
||||
createPercentSecureAnimator(percentSecureListener),
|
||||
createLottieAnimator(lottieListener))
|
||||
.filter(a -> a != null)
|
||||
.toArray(ValueAnimator[]::new);
|
||||
|
||||
animatorSet.setInterpolator(new DecelerateInterpolator());
|
||||
animatorSet.playTogether(animators);
|
||||
|
||||
return animatorSet;
|
||||
}
|
||||
|
||||
private static @Nullable Animator createProgressAnimator(int securePercent, @Nullable UpdateListener updateListener) {
|
||||
if (updateListener == null) return null;
|
||||
|
||||
final ValueAnimator progressAnimator = ValueAnimator.ofFloat(0, securePercent / 100f);
|
||||
|
||||
progressAnimator.setDuration(PROGRESS_ANIMATION_DURATION);
|
||||
progressAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue()));
|
||||
|
||||
return progressAnimator;
|
||||
}
|
||||
|
||||
private static @Nullable Animator createDetailsAnimator(@Nullable UpdateListener updateListener) {
|
||||
if (updateListener == null) return null;
|
||||
|
||||
final ValueAnimator detailsAnimator = ValueAnimator.ofFloat(0, 1f);
|
||||
|
||||
detailsAnimator.setDuration(DETAILS_ANIMATION_DURATION);
|
||||
detailsAnimator.setStartDelay(ANIMATION_START_DELAY);
|
||||
detailsAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue()));
|
||||
|
||||
return detailsAnimator;
|
||||
}
|
||||
|
||||
private static @Nullable Animator createPercentSecureAnimator(@Nullable UpdateListener updateListener) {
|
||||
if (updateListener == null) return null;
|
||||
|
||||
final ValueAnimator percentSecureAnimator = ValueAnimator.ofFloat(1f, PERCENT_SECURE_MAX_SCALE, 1f);
|
||||
|
||||
percentSecureAnimator.setStartDelay(ANIMATION_START_DELAY);
|
||||
percentSecureAnimator.setDuration(PERCENT_SECURE_ANIMATION_DURATION);
|
||||
percentSecureAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue()));
|
||||
|
||||
return percentSecureAnimator;
|
||||
}
|
||||
|
||||
private static @Nullable Animator createLottieAnimator(@Nullable UpdateListener updateListener) {
|
||||
if (updateListener == null) return null;
|
||||
|
||||
final ValueAnimator lottieAnimator = ValueAnimator.ofFloat(0, 1f);
|
||||
|
||||
lottieAnimator.setStartDelay(ANIMATION_START_DELAY);
|
||||
lottieAnimator.setDuration(LOTTIE_ANIMATION_DURATION);
|
||||
lottieAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue()));
|
||||
|
||||
return lottieAnimator;
|
||||
}
|
||||
|
||||
interface UpdateListener {
|
||||
void onUpdate(float value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class InsightsConstants {
|
||||
|
||||
public static final long PERIOD_IN_DAYS = 7L;
|
||||
public static final long PERIOD_IN_MILLIS = TimeUnit.DAYS.toMillis(PERIOD_IN_DAYS);
|
||||
|
||||
private InsightsConstants() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorSet;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.airbnb.lottie.LottieAnimationView;
|
||||
|
||||
import org.thoughtcrime.securesms.NewConversationActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ArcProgressBar;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class InsightsDashboardDialogFragment extends DialogFragment {
|
||||
|
||||
private TextView securePercentage;
|
||||
private ArcProgressBar progress;
|
||||
private View progressContainer;
|
||||
private TextView tagline;
|
||||
private TextView encryptedMessages;
|
||||
private TextView title;
|
||||
private TextView description;
|
||||
private RecyclerView insecureRecipients;
|
||||
private TextView locallyGenerated;
|
||||
private AvatarImageView avatarImageView;
|
||||
private InsightsInsecureRecipientsAdapter adapter;
|
||||
private LottieAnimationView lottieAnimationView;
|
||||
private AnimatorSet animatorSet;
|
||||
private Button startAConversation;
|
||||
private Toolbar toolbar;
|
||||
private InsightsDashboardViewModel viewModel;
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(@NonNull Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
|
||||
requireFragmentManager().beginTransaction()
|
||||
.detach(this)
|
||||
.attach(this)
|
||||
.commit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (ThemeUtil.isDarkTheme(requireActivity())) {
|
||||
setStyle(STYLE_NO_FRAME, R.style.TextSecure_DarkTheme);
|
||||
} else {
|
||||
setStyle(STYLE_NO_FRAME, R.style.TextSecure_LightTheme);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.insights_dashboard, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
securePercentage = view.findViewById(R.id.insights_dashboard_percent_secure);
|
||||
progress = view.findViewById(R.id.insights_dashboard_progress);
|
||||
progressContainer = view.findViewById(R.id.insights_dashboard_percent_container);
|
||||
encryptedMessages = view.findViewById(R.id.insights_dashboard_encrypted_messages);
|
||||
tagline = view.findViewById(R.id.insights_dashboard_tagline);
|
||||
title = view.findViewById(R.id.insights_dashboard_make_signal_secure);
|
||||
description = view.findViewById(R.id.insights_dashboard_invite_your_contacts);
|
||||
insecureRecipients = view.findViewById(R.id.insights_dashboard_recycler);
|
||||
locallyGenerated = view.findViewById(R.id.insights_dashboard_this_stat_was_generated_locally);
|
||||
avatarImageView = view.findViewById(R.id.insights_dashboard_avatar);
|
||||
startAConversation = view.findViewById(R.id.insights_dashboard_start_a_conversation);
|
||||
lottieAnimationView = view.findViewById(R.id.insights_dashboard_lottie_animation);
|
||||
toolbar = view.findViewById(R.id.insights_dashboard_toolbar);
|
||||
|
||||
setupStartAConversation();
|
||||
setDashboardDetailsAlpha(0f);
|
||||
setNotEnoughDataAlpha(0f);
|
||||
setupToolbar();
|
||||
setupRecycler();
|
||||
initializeViewModel();
|
||||
}
|
||||
|
||||
private void setupStartAConversation() {
|
||||
startAConversation.setOnClickListener(v -> startActivity(new Intent(requireActivity(), NewConversationActivity.class)));
|
||||
}
|
||||
|
||||
private void setDashboardDetailsAlpha(float alpha) {
|
||||
tagline.setAlpha(alpha);
|
||||
title.setAlpha(alpha);
|
||||
description.setAlpha(alpha);
|
||||
insecureRecipients.setAlpha(alpha);
|
||||
locallyGenerated.setAlpha(alpha);
|
||||
encryptedMessages.setAlpha(alpha);
|
||||
}
|
||||
|
||||
private void setupToolbar() {
|
||||
toolbar.setNavigationOnClickListener(v -> dismiss());
|
||||
}
|
||||
|
||||
private void setupRecycler() {
|
||||
adapter = new InsightsInsecureRecipientsAdapter(this::handleInviteRecipient);
|
||||
insecureRecipients.setAdapter(adapter);
|
||||
}
|
||||
|
||||
private void initializeViewModel() {
|
||||
final InsightsDashboardViewModel.Repository repository = new InsightsRepository(requireContext());
|
||||
final InsightsDashboardViewModel.Factory factory = new InsightsDashboardViewModel.Factory(repository);
|
||||
|
||||
viewModel = ViewModelProviders.of(this, factory).get(InsightsDashboardViewModel.class);
|
||||
|
||||
viewModel.getState().observe(this, state -> {
|
||||
updateInsecurePercent(state.getData());
|
||||
updateInsecureRecipients(state.getInsecureRecipients());
|
||||
updateUserAvatar(state.getUserAvatar());
|
||||
});
|
||||
}
|
||||
|
||||
private void updateInsecurePercent(@Nullable InsightsData insightsData) {
|
||||
if (insightsData == null) return;
|
||||
|
||||
if (insightsData.hasEnoughData()) {
|
||||
setTitleAndDescriptionText(insightsData.getPercentInsecure());
|
||||
animateProgress(insightsData.getPercentInsecure());
|
||||
} else {
|
||||
setNotEnoughDataText();
|
||||
animateNotEnoughData();
|
||||
}
|
||||
}
|
||||
|
||||
private void animateProgress(int insecurePercent) {
|
||||
startAConversation.setVisibility(View.GONE);
|
||||
if (animatorSet == null) {
|
||||
animatorSet = InsightsAnimatorSetFactory.create(insecurePercent,
|
||||
this::setProgressPercentage,
|
||||
this::setDashboardDetailsAlpha,
|
||||
this::setPercentSecureScale,
|
||||
insecurePercent == 0 ? this::setLottieProgress : null);
|
||||
|
||||
if (insecurePercent == 0) {
|
||||
animatorSet.addListener(new ToolbarBackgroundColorAnimationListener());
|
||||
}
|
||||
|
||||
animatorSet.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void setProgressPercentage(float percent) {
|
||||
securePercentage.setText(String.valueOf(Math.round(percent * 100)));
|
||||
progress.setProgress(percent);
|
||||
}
|
||||
|
||||
private void setPercentSecureScale(float scale) {
|
||||
progressContainer.setScaleX(scale);
|
||||
progressContainer.setScaleY(scale);
|
||||
}
|
||||
|
||||
private void setLottieProgress(float progress) {
|
||||
lottieAnimationView.setProgress(progress);
|
||||
}
|
||||
|
||||
private void setTitleAndDescriptionText(int insecurePercent) {
|
||||
startAConversation.setVisibility(View.GONE);
|
||||
progressContainer.setVisibility(View.VISIBLE);
|
||||
insecureRecipients.setVisibility(View.VISIBLE);
|
||||
encryptedMessages.setText(R.string.InsightsDashboardFragment__encrypted_messages);
|
||||
tagline.setText(getString(R.string.InsightsDashboardFragment__signal_protocol_automatically_protected, 100 - insecurePercent, InsightsConstants.PERIOD_IN_DAYS));
|
||||
|
||||
if (insecurePercent == 0) {
|
||||
lottieAnimationView.setVisibility(View.VISIBLE);
|
||||
title.setVisibility(View.GONE);
|
||||
description.setVisibility(View.GONE);
|
||||
} else {
|
||||
lottieAnimationView.setVisibility(View.GONE);
|
||||
title.setText(R.string.InsightsDashboardFragment__boost_your_signal);
|
||||
description.setText(R.string.InsightsDashboardFragment__invite_your_contacts);
|
||||
title.setVisibility(View.VISIBLE);
|
||||
description.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void setNotEnoughDataText() {
|
||||
startAConversation.setVisibility(View.VISIBLE);
|
||||
progressContainer.setVisibility(View.INVISIBLE);
|
||||
insecureRecipients.setVisibility(View.GONE);
|
||||
encryptedMessages.setText(R.string.InsightsDashboardFragment__not_enough_data);
|
||||
tagline.setText(getString(R.string.InsightsDashboardFragment__your_insights_percentage_is_calculated_based_on, InsightsConstants.PERIOD_IN_DAYS));
|
||||
}
|
||||
|
||||
private void animateNotEnoughData() {
|
||||
if (animatorSet == null) {
|
||||
animatorSet = InsightsAnimatorSetFactory.create(0, null, this::setNotEnoughDataAlpha, null, null);
|
||||
animatorSet.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void setNotEnoughDataAlpha(float alpha) {
|
||||
encryptedMessages.setAlpha(alpha);
|
||||
tagline.setAlpha(alpha);
|
||||
startAConversation.setAlpha(alpha);
|
||||
}
|
||||
|
||||
private void updateInsecureRecipients(@NonNull List<Recipient> recipients) {
|
||||
adapter.updateData(recipients);
|
||||
}
|
||||
|
||||
private void updateUserAvatar(@Nullable InsightsUserAvatar userAvatar) {
|
||||
if (userAvatar == null) avatarImageView.setImageDrawable(null);
|
||||
else userAvatar.load(avatarImageView);
|
||||
}
|
||||
|
||||
private void handleInviteRecipient(final @NonNull Recipient recipient) {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_invites, 1, 1))
|
||||
.setMessage(getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url)))
|
||||
.setPositiveButton(R.string.InsightsDashboardFragment__send, (dialog, which) -> viewModel.sendSmsInvite(recipient))
|
||||
.setNegativeButton(R.string.InsightsDashboardFragment__cancel, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
if (animatorSet != null) {
|
||||
animatorSet.cancel();
|
||||
animatorSet = null;
|
||||
}
|
||||
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
private final class ToolbarBackgroundColorAnimationListener implements Animator.AnimatorListener {
|
||||
|
||||
@Override
|
||||
public void onAnimationStart(Animator animation) {
|
||||
toolbar.setBackgroundResource(R.color.transparent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
toolbar.setBackgroundColor(ThemeUtil.getThemedColor(requireContext(), android.R.attr.windowBackground));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationCancel(Animator animation) {
|
||||
toolbar.setBackgroundColor(ThemeUtil.getThemedColor(requireContext(), android.R.attr.windowBackground));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationRepeat(Animator animation) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
final class InsightsDashboardState {
|
||||
|
||||
private final List<Recipient> insecureRecipients;
|
||||
private final InsightsData insightsData;
|
||||
private final InsightsUserAvatar userAvatar;
|
||||
|
||||
private InsightsDashboardState(@NonNull Builder builder) {
|
||||
this.insecureRecipients = builder.insecureRecipients;
|
||||
this.insightsData = builder.insightsData;
|
||||
this.userAvatar = builder.userAvatar;
|
||||
}
|
||||
|
||||
static @NonNull InsightsDashboardState.Builder builder() {
|
||||
return new InsightsDashboardState.Builder();
|
||||
}
|
||||
|
||||
@NonNull InsightsDashboardState.Builder buildUpon() {
|
||||
return builder().withData(insightsData).withUserAvatar(userAvatar).withInsecureRecipients(insecureRecipients);
|
||||
}
|
||||
|
||||
@NonNull List<Recipient> getInsecureRecipients() {
|
||||
return insecureRecipients;
|
||||
}
|
||||
|
||||
@Nullable InsightsUserAvatar getUserAvatar() {
|
||||
return userAvatar;
|
||||
}
|
||||
|
||||
@Nullable InsightsData getData() {
|
||||
return insightsData;
|
||||
}
|
||||
|
||||
static final class Builder {
|
||||
private List<Recipient> insecureRecipients = Collections.emptyList();
|
||||
private InsightsUserAvatar userAvatar;
|
||||
private InsightsData insightsData;
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
|
||||
@NonNull Builder withInsecureRecipients(@NonNull List<Recipient> insecureRecipients) {
|
||||
this.insecureRecipients = insecureRecipients;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withData(@NonNull InsightsData insightsData) {
|
||||
this.insightsData = insightsData;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withUserAvatar(@NonNull InsightsUserAvatar userAvatar) {
|
||||
this.userAvatar = userAvatar;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull InsightsDashboardState build() {
|
||||
return new InsightsDashboardState(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class InsightsDashboardViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<InsightsDashboardState> internalState = new MutableLiveData<>(InsightsDashboardState.builder().build());
|
||||
private final Repository repository;
|
||||
|
||||
private InsightsDashboardViewModel(@NonNull Repository repository) {
|
||||
this.repository = repository;
|
||||
|
||||
repository.getInsightsData(data -> internalState.setValue(getNewState(b -> b.withData(data))));
|
||||
repository.getUserAvatar(avatar -> internalState.setValue(getNewState(b -> b.withUserAvatar(avatar))));
|
||||
updateInsecureRecipients();
|
||||
}
|
||||
|
||||
private void updateInsecureRecipients() {
|
||||
repository.getInsecureRecipients(recipients -> internalState.setValue(getNewState(b -> b.withInsecureRecipients(recipients))));
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private InsightsDashboardState getNewState(Consumer<InsightsDashboardState.Builder> builderConsumer) {
|
||||
InsightsDashboardState.Builder builder = internalState.getValue().buildUpon();
|
||||
builderConsumer.accept(builder);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@NonNull LiveData<InsightsDashboardState> getState() {
|
||||
return internalState;
|
||||
}
|
||||
|
||||
public void sendSmsInvite(@NonNull Recipient recipient) {
|
||||
repository.sendSmsInvite(recipient, this::updateInsecureRecipients);
|
||||
}
|
||||
|
||||
interface Repository {
|
||||
void getInsightsData(@NonNull Consumer<InsightsData> insightsDataConsumer);
|
||||
void getInsecureRecipients(@NonNull Consumer<List<Recipient>> insecureRecipientsConsumer);
|
||||
void getUserAvatar(@NonNull Consumer<InsightsUserAvatar> userAvatarConsumer);
|
||||
void sendSmsInvite(@NonNull Recipient recipient, Runnable onSmsMessageSent);
|
||||
}
|
||||
|
||||
final static class Factory implements ViewModelProvider.Factory {
|
||||
|
||||
private final Repository repository;
|
||||
|
||||
Factory(@NonNull Repository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
return (T) new InsightsDashboardViewModel(repository);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
final class InsightsData {
|
||||
private final boolean hasEnoughData;
|
||||
private final int percentInsecure;
|
||||
|
||||
InsightsData(boolean hasEnoughData, int percentInsecure) {
|
||||
this.hasEnoughData = hasEnoughData;
|
||||
this.percentInsecure = percentInsecure;
|
||||
}
|
||||
|
||||
public boolean hasEnoughData() {
|
||||
return hasEnoughData;
|
||||
}
|
||||
|
||||
public int getPercentInsecure() {
|
||||
return percentInsecure;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
final class InsightsInsecureRecipientsAdapter extends RecyclerView.Adapter<InsightsInsecureRecipientsAdapter.ViewHolder> {
|
||||
|
||||
private List<Recipient> data = Collections.emptyList();
|
||||
|
||||
private final Consumer<Recipient> onInviteClickedConsumer;
|
||||
|
||||
InsightsInsecureRecipientsAdapter(Consumer<Recipient> onInviteClickedConsumer) {
|
||||
this.onInviteClickedConsumer = onInviteClickedConsumer;
|
||||
}
|
||||
|
||||
public void updateData(List<Recipient> recipients) {
|
||||
List<Recipient> oldData = data;
|
||||
data = recipients;
|
||||
|
||||
DiffUtil.calculateDiff(new DiffCallback(oldData, data)).dispatchUpdatesTo(this);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.insights_dashboard_adapter_item, parent, false), this::handleInviteClicked);
|
||||
}
|
||||
|
||||
private void handleInviteClicked(@NonNull Integer position) {
|
||||
onInviteClickedConsumer.accept(data.get(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
holder.bind(data.get(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return data.size();
|
||||
}
|
||||
|
||||
static final class ViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private AvatarImageView avatarImageView;
|
||||
private TextView displayName;
|
||||
|
||||
private ViewHolder(@NonNull View itemView, Consumer<Integer> onInviteClicked) {
|
||||
super(itemView);
|
||||
|
||||
avatarImageView = itemView.findViewById(R.id.recipient_avatar);
|
||||
displayName = itemView.findViewById(R.id.recipient_display_name);
|
||||
|
||||
Button invite = itemView.findViewById(R.id.recipient_invite);
|
||||
invite.setOnClickListener(v -> {
|
||||
int adapterPosition = getAdapterPosition();
|
||||
|
||||
if (adapterPosition == RecyclerView.NO_POSITION) return;
|
||||
|
||||
onInviteClicked.accept(adapterPosition);
|
||||
});
|
||||
}
|
||||
|
||||
private void bind(@NonNull Recipient recipient) {
|
||||
displayName.setText(recipient.getDisplayName(itemView.getContext()));
|
||||
avatarImageView.setAvatar(GlideApp.with(itemView), recipient, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static class DiffCallback extends DiffUtil.Callback {
|
||||
|
||||
private final List<Recipient> oldData;
|
||||
private final List<Recipient> newData;
|
||||
|
||||
private DiffCallback(@NonNull List<Recipient> oldData,
|
||||
@NonNull List<Recipient> newData)
|
||||
{
|
||||
this.oldData = oldData;
|
||||
this.newData = newData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOldListSize() {
|
||||
return oldData.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getNewListSize() {
|
||||
return newData.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
|
||||
return oldData.get(oldItemPosition).getId() == newData.get(newItemPosition).getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
|
||||
return oldData.get(oldItemPosition).equals(newData.get(newItemPosition));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
|
||||
public final class InsightsLauncher {
|
||||
|
||||
private static final String MODAL_TAG = "modal.fragment";
|
||||
|
||||
public static void showInsightsModal(@NonNull Context context, @NonNull FragmentManager fragmentManager) {
|
||||
if (InsightsOptOut.userHasOptedOut(context)) return;
|
||||
|
||||
final Fragment fragment = fragmentManager.findFragmentByTag(MODAL_TAG);
|
||||
|
||||
if (fragment == null) new InsightsModalDialogFragment().show(fragmentManager, MODAL_TAG);
|
||||
}
|
||||
|
||||
public static void showInsightsDashboard(@NonNull FragmentManager fragmentManager) {
|
||||
new InsightsDashboardDialogFragment().show(fragmentManager, null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import android.animation.AnimatorSet;
|
||||
import android.app.Dialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ArcProgressBar;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
|
||||
public final class InsightsModalDialogFragment extends DialogFragment {
|
||||
|
||||
private ArcProgressBar progress;
|
||||
private TextView securePercentage;
|
||||
private AvatarImageView avatarImageView;
|
||||
private AnimatorSet animatorSet;
|
||||
private View progressContainer;
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(@NonNull Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
|
||||
requireFragmentManager().beginTransaction()
|
||||
.detach(this)
|
||||
.attach(this)
|
||||
.commit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setStyle(STYLE_NO_FRAME, R.style.Theme_Signal_Insights_Modal);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
|
||||
Dialog dialog = super.onCreateDialog(savedInstanceState);
|
||||
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.insights_modal, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
View close = view.findViewById(R.id.insights_modal_close);
|
||||
Button viewInsights = view.findViewById(R.id.insights_modal_view_insights);
|
||||
|
||||
progress = view.findViewById(R.id.insights_modal_progress);
|
||||
securePercentage = view.findViewById(R.id.insights_modal_percent_secure);
|
||||
avatarImageView = view.findViewById(R.id.insights_modal_avatar);
|
||||
progressContainer = view.findViewById(R.id.insights_modal_percent_container);
|
||||
|
||||
close.setOnClickListener(v -> dismiss());
|
||||
viewInsights.setOnClickListener(v -> openInsightsAndDismiss());
|
||||
|
||||
initializeViewModel();
|
||||
}
|
||||
|
||||
private void initializeViewModel() {
|
||||
final InsightsModalViewModel.Repository repository = new InsightsRepository(requireContext());
|
||||
final InsightsModalViewModel.Factory factory = new InsightsModalViewModel.Factory(repository);
|
||||
final InsightsModalViewModel viewModel = ViewModelProviders.of(this, factory).get(InsightsModalViewModel.class);
|
||||
|
||||
viewModel.getState().observe(this, state -> {
|
||||
updateInsecurePercent(state.getData());
|
||||
updateUserAvatar(state.getUserAvatar());
|
||||
});
|
||||
}
|
||||
|
||||
private void updateInsecurePercent(@Nullable InsightsData insightsData) {
|
||||
if (insightsData == null) return;
|
||||
|
||||
if (animatorSet == null) {
|
||||
animatorSet = InsightsAnimatorSetFactory.create(insightsData.getPercentInsecure(), this::setProgressPercentage, null, this::setPercentSecureScale, null);
|
||||
animatorSet.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void setProgressPercentage(float percent) {
|
||||
securePercentage.setText(String.valueOf(Math.round(percent * 100)));
|
||||
progress.setProgress(percent);
|
||||
}
|
||||
|
||||
private void setPercentSecureScale(float scale) {
|
||||
progressContainer.setScaleX(scale);
|
||||
progressContainer.setScaleY(scale);
|
||||
}
|
||||
|
||||
private void updateUserAvatar(@Nullable InsightsUserAvatar userAvatar) {
|
||||
if (userAvatar == null) avatarImageView.setImageDrawable(null);
|
||||
else userAvatar.load(avatarImageView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismiss(@NonNull DialogInterface dialog) {
|
||||
InsightsOptOut.userRequestedOptOut(requireContext());
|
||||
}
|
||||
|
||||
private void openInsightsAndDismiss() {
|
||||
InsightsLauncher.showInsightsDashboard(requireFragmentManager());
|
||||
dismiss();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
if (animatorSet != null) {
|
||||
animatorSet.cancel();
|
||||
animatorSet = null;
|
||||
}
|
||||
|
||||
super.onDestroyView();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class InsightsModalState {
|
||||
|
||||
private final InsightsData insightsData;
|
||||
private final InsightsUserAvatar userAvatar;
|
||||
|
||||
private InsightsModalState(@NonNull Builder builder) {
|
||||
this.insightsData = builder.insightsData;
|
||||
this.userAvatar = builder.userAvatar;
|
||||
}
|
||||
|
||||
static @NonNull InsightsModalState.Builder builder() {
|
||||
return new InsightsModalState.Builder();
|
||||
}
|
||||
|
||||
@NonNull InsightsModalState.Builder buildUpon() {
|
||||
return builder().withUserAvatar(userAvatar).withData(insightsData);
|
||||
}
|
||||
|
||||
@Nullable InsightsUserAvatar getUserAvatar() {
|
||||
return userAvatar;
|
||||
}
|
||||
|
||||
@Nullable InsightsData getData() {
|
||||
return insightsData;
|
||||
}
|
||||
|
||||
static final class Builder {
|
||||
private InsightsData insightsData;
|
||||
private InsightsUserAvatar userAvatar;
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
|
||||
@NonNull Builder withData(@NonNull InsightsData insightsData) {
|
||||
this.insightsData = insightsData;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withUserAvatar(@NonNull InsightsUserAvatar userAvatar) {
|
||||
this.userAvatar = userAvatar;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull InsightsModalState build() {
|
||||
return new InsightsModalState(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
final class InsightsModalViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<InsightsModalState> internalState = new MutableLiveData<>(InsightsModalState.builder().build());
|
||||
|
||||
private InsightsModalViewModel(@NonNull Repository repository) {
|
||||
repository.getInsightsData(data -> internalState.setValue(getNewState(b -> b.withData(data))));
|
||||
repository.getUserAvatar(avatar -> internalState.setValue(getNewState(b -> b.withUserAvatar(avatar))));
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private InsightsModalState getNewState(Consumer<InsightsModalState.Builder> builderConsumer) {
|
||||
InsightsModalState.Builder builder = internalState.getValue().buildUpon();
|
||||
builderConsumer.accept(builder);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@NonNull LiveData<InsightsModalState> getState() {
|
||||
return internalState;
|
||||
}
|
||||
|
||||
interface Repository {
|
||||
void getInsightsData(Consumer<InsightsData> insecurePercentConsumer);
|
||||
void getUserAvatar(@NonNull Consumer<InsightsUserAvatar> userAvatarConsumer);
|
||||
}
|
||||
|
||||
final static class Factory implements ViewModelProvider.Factory {
|
||||
|
||||
private final Repository repository;
|
||||
|
||||
Factory(@NonNull Repository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
return (T) new InsightsModalViewModel(repository);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
public final class InsightsOptOut {
|
||||
private static final String INSIGHTS_OPT_OUT_PREFERENCE = "insights.opt.out";
|
||||
|
||||
private InsightsOptOut() {
|
||||
}
|
||||
|
||||
static boolean userHasOptedOut(@NonNull Context context) {
|
||||
return TextSecurePreferences.getBooleanPreference(context, INSIGHTS_OPT_OUT_PREFERENCE, false);
|
||||
}
|
||||
|
||||
public static void userRequestedOptOut(@NonNull Context context) {
|
||||
TextSecurePreferences.setBooleanPreference(context, INSIGHTS_OPT_OUT_PREFERENCE, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class InsightsRepository implements InsightsDashboardViewModel.Repository, InsightsModalViewModel.Repository {
|
||||
|
||||
private final Context context;
|
||||
|
||||
public InsightsRepository(Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void getInsightsData(@NonNull Consumer<InsightsData> insightsDataConsumer) {
|
||||
SimpleTask.run(() -> {
|
||||
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
int insecure = mmsSmsDatabase.getInsecureMessageCountForInsights();
|
||||
int secure = mmsSmsDatabase.getSecureMessageCountForInsights();
|
||||
|
||||
if (insecure + secure == 0) {
|
||||
return new InsightsData(false, 0);
|
||||
} else {
|
||||
return new InsightsData(true, Util.clamp((int) Math.ceil((insecure * 100f) / (insecure + secure)), 0, 100));
|
||||
}
|
||||
}, insightsDataConsumer::accept);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void getInsecureRecipients(@NonNull Consumer<List<Recipient>> insecureRecipientsConsumer) {
|
||||
SimpleTask.run(() -> {
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
List<RecipientId> unregisteredRecipients = recipientDatabase.getUninvitedRecipientsForInsights();
|
||||
|
||||
return Stream.of(unregisteredRecipients)
|
||||
.map(Recipient::resolved)
|
||||
.toList();
|
||||
},
|
||||
insecureRecipientsConsumer::accept);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void getUserAvatar(@NonNull Consumer<InsightsUserAvatar> avatarConsumer) {
|
||||
SimpleTask.run(() -> {
|
||||
Recipient self = Recipient.self().resolve();
|
||||
String name = Optional.fromNullable(self.getName(context)).or(Optional.fromNullable(TextSecurePreferences.getProfileName(context))).or("");
|
||||
MaterialColor fallbackColor = self.getColor();
|
||||
|
||||
if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) {
|
||||
fallbackColor = ContactColors.generateFor(name);
|
||||
}
|
||||
|
||||
return new InsightsUserAvatar(new ProfileContactPhoto(self.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(context))),
|
||||
fallbackColor,
|
||||
new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40));
|
||||
}, avatarConsumer::accept);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSmsInvite(@NonNull Recipient recipient, Runnable onSmsMessageSent) {
|
||||
SimpleTask.run(() -> {
|
||||
Recipient resolved = recipient.resolve();
|
||||
int subscriptionId = resolved.getDefaultSubscriptionId().or(-1);
|
||||
String message = context.getString(R.string.InviteActivity_lets_switch_to_signal, context.getString(R.string.install_url));
|
||||
|
||||
MessageSender.send(context, new OutgoingTextMessage(resolved, message, subscriptionId), -1L, true, null);
|
||||
|
||||
RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context);
|
||||
database.setHasSentInvite(recipient.getId());
|
||||
|
||||
return null;
|
||||
}, v -> onSmsMessageSent.run());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.insights;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
class InsightsUserAvatar {
|
||||
private final ProfileContactPhoto profileContactPhoto;
|
||||
private final MaterialColor fallbackColor;
|
||||
private final FallbackContactPhoto fallbackContactPhoto;
|
||||
|
||||
InsightsUserAvatar(@NonNull ProfileContactPhoto profileContactPhoto, @NonNull MaterialColor fallbackColor, @NonNull FallbackContactPhoto fallbackContactPhoto) {
|
||||
this.profileContactPhoto = profileContactPhoto;
|
||||
this.fallbackColor = fallbackColor;
|
||||
this.fallbackContactPhoto = fallbackContactPhoto;
|
||||
}
|
||||
|
||||
private Drawable fallbackDrawable(@NonNull Context context) {
|
||||
return fallbackContactPhoto.asDrawable(context, fallbackColor.toAvatarColor(context));
|
||||
}
|
||||
|
||||
void load(ImageView into) {
|
||||
GlideApp.with(into)
|
||||
.load(profileContactPhoto)
|
||||
.error(fallbackDrawable(into.getContext()))
|
||||
.circleCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.into(into);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user