Move all files to natural position.

This commit is contained in:
Alan Evans
2020-01-06 10:52:48 -05:00
parent 0df36047e7
commit 9ebe920195
3016 changed files with 6 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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