Implement in-app insights.

This commit is contained in:
Alex Hart
2019-11-12 10:18:57 -04:00
committed by Alan Evans
parent e2e9cd40b3
commit a7dd78cce6
56 changed files with 2541 additions and 127 deletions

View File

@@ -25,10 +25,6 @@ import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuInflater;
@@ -38,6 +34,10 @@ import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.color.MaterialColor;
@@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.lock.RegistrationLockDialog;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
@@ -131,6 +132,7 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
inflater.inflate(R.menu.text_secure_normal, menu);
menu.findItem(R.id.menu_insights).setVisible(TextSecurePreferences.isSmsEnabled(this));
menu.findItem(R.id.menu_clear_passphrase).setVisible(!TextSecurePreferences.isPasswordDisabled(this));
super.onPrepareOptionsMenu(menu);
@@ -212,6 +214,7 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
case R.id.menu_clear_passphrase: handleClearPassphrase(); return true;
case R.id.menu_mark_all_read: handleMarkAllRead(); return true;
case R.id.menu_invite: handleInvite(); return true;
case R.id.menu_insights: handleInsights(); return true;
case R.id.menu_help: handleHelp(); return true;
}
@@ -300,6 +303,10 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
startActivity(new Intent(this, InviteActivity.class));
}
private void handleInsights() {
InsightsLauncher.showInsightsDashboard(getSupportFragmentManager());
}
private void handleHelp() {
try {
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://support.signal.org")));

View File

@@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.loaders.ConversationListLoader;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mms.GlideApp;
@@ -84,6 +85,7 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
@@ -188,6 +190,10 @@ public class ConversationListFragment extends Fragment
updateReminders(true);
list.getAdapter().notifyDataSetChanged();
EventBus.getDefault().register(this);
if (TextSecurePreferences.isSmsEnabled(requireContext())) {
InsightsLauncher.showInsightsModal(requireContext(), requireFragmentManager());
}
}
@Override

View File

@@ -5,15 +5,12 @@ import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import androidx.annotation.AnimRes;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import androidx.appcompat.app.AlertDialog;
import android.view.View;
@@ -237,7 +234,7 @@ public class InviteActivity extends PassphraseRequiredActionBarActivity implemen
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null);
if (recipient.getContactUri() != null) {
DatabaseFactory.getRecipientDatabase(context).setSeenInviteReminder(recipient.getId(), true);
DatabaseFactory.getRecipientDatabase(context).setHasSentInvite(recipient.getId());
}
}

View File

@@ -0,0 +1,125 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.Util;
public class ArcProgressBar extends View {
private static final int DEFAULT_WIDTH = 10;
private static final float DEFAULT_PROGRESS = 0f;
private static final int DEFAULT_BACKGROUND_COLOR = 0xFF000000;
private static final int DEFAULT_FOREGROUND_COLOR = 0xFFFFFFFF;
private static final float DEFAULT_START_ANGLE = 0f;
private static final float DEFAULT_SWEEP_ANGLE = 360f;
private static final boolean DEFAULT_ROUNDED_ENDS = true;
private static final String SUPER = "arcprogressbar.super";
private static final String PROGRESS = "arcprogressbar.progress";
private float progress;
private final float width;
private final RectF arcRect = new RectF();
private final Paint arcBackgroundPaint;
private final Paint arcForegroundPaint;
private final float arcStartAngle;
private final float arcSweepAngle;
public ArcProgressBar(@NonNull Context context) {
this(context, null);
}
public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray attributes = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ArcProgressBar, defStyleAttr, 0);
width = attributes.getDimensionPixelSize(R.styleable.ArcProgressBar_arcWidth, DEFAULT_WIDTH);
progress = attributes.getFloat(R.styleable.ArcProgressBar_arcProgress, DEFAULT_PROGRESS);
arcBackgroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcBackgroundColor, DEFAULT_BACKGROUND_COLOR));
arcForegroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcForegroundColor, DEFAULT_FOREGROUND_COLOR));
arcStartAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcStartAngle, DEFAULT_START_ANGLE);
arcSweepAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcSweepAngle, DEFAULT_SWEEP_ANGLE);
if (attributes.getBoolean(R.styleable.ArcProgressBar_arcRoundedEnds, DEFAULT_ROUNDED_ENDS)) {
arcForegroundPaint.setStrokeCap(Paint.Cap.ROUND);
if (arcSweepAngle <= 360f) {
arcBackgroundPaint.setStrokeCap(Paint.Cap.ROUND);
}
}
attributes.recycle();
}
private static Paint createPaint(float width, @ColorInt int color) {
Paint paint = new Paint();
paint.setStrokeWidth(width);
paint.setStyle(Paint.Style.STROKE);
paint.setAntiAlias(true);
paint.setColor(color);
return paint;
}
public void setProgress(float progress) {
if (this.progress != progress) {
this.progress = progress;
invalidate();
}
}
@Override
protected @Nullable Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putParcelable(SUPER, superState);
bundle.putFloat(PROGRESS, progress);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state.getClass() != Bundle.class) throw new IllegalStateException("Expected");
Bundle restoreState = (Bundle) state;
Parcelable superState = restoreState.getParcelable(SUPER);
super.onRestoreInstanceState(superState);
progress = restoreState.getLong(PROGRESS);
}
@Override
protected void onDraw(Canvas canvas) {
float halfWidth = width / 2f;
arcRect.set(0 + halfWidth,
0 + halfWidth,
getWidth() - halfWidth,
getHeight() - halfWidth);
canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle, false, arcBackgroundPaint);
canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle * Util.clamp(progress, 0f, 1f), false, arcForegroundPaint);
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
public final class FirstInviteReminder extends Reminder {
public FirstInviteReminder(final @NonNull Context context,
final @NonNull Recipient recipient,
final int percentIncrease) {
super(context.getString(R.string.FirstInviteReminder__title),
context.getString(R.string.FirstInviteReminder__description, percentIncrease));
addAction(new Action(context.getString(R.string.InsightsReminder__invite), R.id.reminder_action_invite));
addAction(new Action(context.getString(R.string.InsightsReminder__view_insights), R.id.reminder_action_view_insights));
}
}

View File

@@ -1,28 +0,0 @@
package org.thoughtcrime.securesms.components.reminder;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.AsyncTask;
import androidx.annotation.NonNull;
import android.view.View;
import android.view.View.OnClickListener;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
public class InviteReminder extends Reminder {
@SuppressLint("StaticFieldLeak")
public InviteReminder(final @NonNull Context context,
final @NonNull Recipient recipient)
{
super(context.getString(R.string.reminder_header_invite_title),
context.getString(R.string.reminder_header_invite_text, recipient.toShortString(context)));
setDismissListener(v -> SignalExecutors.BOUNDED.execute(() -> {
DatabaseFactory.getRecipientDatabase(context).setSeenInviteReminder(recipient.getId(), true);
}));
}
}

View File

@@ -1,9 +1,15 @@
package org.thoughtcrime.securesms.components.reminder;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.View;
import android.view.View.OnClickListener;
import java.util.LinkedList;
import java.util.List;
public abstract class Reminder {
private CharSequence title;
private CharSequence text;
@@ -11,6 +17,8 @@ public abstract class Reminder {
private OnClickListener okListener;
private OnClickListener dismissListener;
private final List<Action> actions = new LinkedList<>();
public Reminder(@Nullable CharSequence title,
@NonNull CharSequence text)
{
@@ -50,8 +58,37 @@ public abstract class Reminder {
return Importance.NORMAL;
}
public void addAction(@NonNull Action action) {
actions.add(action);
}
public List<Action> getActions() {
return actions;
}
public int getProgress() {
return -1;
}
public enum Importance {
NORMAL, ERROR
}
public final class Action {
private final CharSequence title;
private final int actionId;
public Action(CharSequence title, @IdRes int actionId) {
this.title = title;
this.actionId = actionId;
}
CharSequence getTitle() {
return title;
}
int getActionId() {
return actionId;
}
}
}

View File

@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.components.reminder;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import java.util.Collections;
import java.util.List;
final class ReminderActionsAdapter extends RecyclerView.Adapter<ReminderActionsAdapter.ActionViewHolder> {
private final List<Reminder.Action> actions;
private final ReminderView.OnActionClickListener actionClickListener;
ReminderActionsAdapter(List<Reminder.Action> actions, ReminderView.OnActionClickListener actionClickListener) {
this.actions = Collections.unmodifiableList(actions);
this.actionClickListener = actionClickListener;
}
@NonNull
@Override
public ActionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ActionViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reminder_action_button, parent, false));
}
@Override
public void onBindViewHolder(@NonNull ActionViewHolder holder, int position) {
final Reminder.Action action = actions.get(position);
((Button) holder.itemView).setText(action.getTitle());
holder.itemView.setOnClickListener(v -> {
if (holder.getAdapterPosition() == RecyclerView.NO_POSITION) return;
actionClickListener.onActionClick(action.getActionId());
});
}
@Override
public int getItemCount() {
return actions.size();
}
final class ActionViewHolder extends RecyclerView.ViewHolder {
ActionViewHolder(@NonNull View itemView) {
super(itemView);
}
}
}

View File

@@ -8,22 +8,35 @@ import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.Space;
import android.widget.TextView;
import androidx.annotation.IdRes;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
/**
* View to display actionable reminders to the user
*/
public class ReminderView extends LinearLayout {
private ViewGroup container;
private ImageButton closeButton;
private TextView title;
private TextView text;
private OnDismissListener dismissListener;
public final class ReminderView extends FrameLayout {
private ProgressBar progressBar;
private TextView progressText;
private ViewGroup container;
private ImageButton closeButton;
private TextView title;
private TextView text;
private OnDismissListener dismissListener;
private Space space;
private RecyclerView actionsRecycler;
private OnActionClickListener actionClickListener;
public ReminderView(Context context) {
super(context);
@@ -43,19 +56,25 @@ public class ReminderView extends LinearLayout {
private void initialize() {
LayoutInflater.from(getContext()).inflate(R.layout.reminder_header, this, true);
container = ViewUtil.findById(this, R.id.container);
closeButton = ViewUtil.findById(this, R.id.cancel);
title = ViewUtil.findById(this, R.id.reminder_title);
text = ViewUtil.findById(this, R.id.reminder_text);
progressBar = ViewUtil.findById(this, R.id.reminder_progress);
progressText = ViewUtil.findById(this, R.id.reminder_progress_text);
container = ViewUtil.findById(this, R.id.container);
closeButton = ViewUtil.findById(this, R.id.cancel);
title = ViewUtil.findById(this, R.id.reminder_title);
text = ViewUtil.findById(this, R.id.reminder_text);
space = ViewUtil.findById(this, R.id.reminder_space);
actionsRecycler = ViewUtil.findById(this, R.id.reminder_actions);
}
public void showReminder(final Reminder reminder) {
if (!TextUtils.isEmpty(reminder.getTitle())) {
title.setText(reminder.getTitle());
title.setVisibility(VISIBLE);
space.setVisibility(GONE);
} else {
title.setText("");
title.setVisibility(GONE);
space.setVisibility(VISIBLE);
}
text.setText(reminder.getText());
container.setBackgroundResource(reminder.getImportance() == Reminder.Importance.ERROR ? R.drawable.reminder_background_error
@@ -73,13 +92,40 @@ public class ReminderView extends LinearLayout {
}
});
int progress = reminder.getProgress();
if (progress != -1) {
progressBar.setProgress(progress);
progressBar.setVisibility(VISIBLE);
progressText.setText(getContext().getString(R.string.reminder_header_progress, progress));
progressText.setVisibility(VISIBLE);
} else {
progressBar.setVisibility(GONE);
progressText.setVisibility(GONE);
}
List<Reminder.Action> actions = reminder.getActions();
if (actions.isEmpty()) {
actionsRecycler.setVisibility(GONE);
} else {
actionsRecycler.setVisibility(VISIBLE);
actionsRecycler.setAdapter(new ReminderActionsAdapter(actions, this::handleActionClicked));
}
container.setVisibility(View.VISIBLE);
}
private void handleActionClicked(@IdRes int actionId) {
if (actionClickListener != null) actionClickListener.onActionClick(actionId);
}
public void setOnDismissListener(OnDismissListener dismissListener) {
this.dismissListener = dismissListener;
}
public void setOnActionClickListener(@Nullable OnActionClickListener actionClickListener) {
this.actionClickListener = actionClickListener;
}
public void requestDismiss() {
closeButton.performClick();
}
@@ -91,4 +137,8 @@ public class ReminderView extends LinearLayout {
public interface OnDismissListener {
void onDismiss();
}
public interface OnActionClickListener {
void onActionClick(@IdRes int actionId);
}
}

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
public final class SecondInviteReminder extends Reminder {
private final int progress;
public SecondInviteReminder(final @NonNull Context context,
final @NonNull Recipient recipient,
final int percent)
{
super(context.getString(R.string.SecondInviteReminder__title),
context.getString(R.string.SecondInviteReminder__description, recipient.getDisplayName(context)));
this.progress = percent;
addAction(new Action(context.getString(R.string.InsightsReminder__invite), R.id.reminder_action_invite));
addAction(new Action(context.getString(R.string.InsightsReminder__view_insights), R.id.reminder_action_view_insights));
}
@Override
public int getProgress() {
return progress;
}
}

View File

@@ -32,13 +32,11 @@ import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.hardware.Camera;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.os.Vibrator;
import android.provider.Browser;
import android.provider.ContactsContract;
@@ -62,16 +60,15 @@ import android.widget.ImageButton;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.core.view.MenuItemCompat;
import androidx.lifecycle.ViewModelProviders;
@@ -100,7 +97,6 @@ import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
import org.thoughtcrime.securesms.audio.AudioRecorder;
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.components.AttachmentTypeSelector;
@@ -120,12 +116,13 @@ import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
import org.thoughtcrime.securesms.components.identity.UnverifiedSendDialog;
import org.thoughtcrime.securesms.components.location.SignalPlace;
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
import org.thoughtcrime.securesms.components.reminder.InviteReminder;
import org.thoughtcrime.securesms.components.reminder.Reminder;
import org.thoughtcrime.securesms.components.reminder.ReminderView;
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity;
import org.thoughtcrime.securesms.contactshare.ContactUtil;
@@ -152,6 +149,9 @@ import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.invites.InviteReminderModel;
import org.thoughtcrime.securesms.invites.InviteReminderRepository;
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
@@ -210,7 +210,6 @@ import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ExpirationUtil;
@@ -220,7 +219,6 @@ import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.TextSecurePreferences.MediaKeyboardMode;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
@@ -322,6 +320,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private LinkPreviewViewModel linkPreviewViewModel;
private ConversationSearchViewModel searchViewModel;
private ConversationStickerViewModel stickerViewModel;
private InviteReminderModel inviteReminderModel;
private LiveRecipient recipient;
private long threadId;
@@ -399,6 +398,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
});
}
});
initializeInsightObserver();
}
@Override
@@ -816,7 +816,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
@Subscribe(threadMode = ThreadMode.MAIN)
public void onEvent(ReminderUpdateEvent event) {
updateReminders(recipient.get().hasSeenInviteReminder());
updateReminders();
}
@Override
@@ -1423,12 +1423,17 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private void onSecurityUpdated() {
Log.i(TAG, "onSecurityUpdated()");
updateReminders(recipient.get().hasSeenInviteReminder());
updateReminders();
updateDefaultSubscriptionId(recipient.get().getDefaultSubscriptionId());
}
protected void updateReminders(boolean seenInvite) {
Log.i(TAG, "updateReminders(" + seenInvite + ")");
private void initializeInsightObserver() {
inviteReminderModel = new InviteReminderModel(this, new InviteReminderRepository(this));
inviteReminderModel.loadReminder(recipient, this::updateReminders);
}
protected void updateReminders() {
Optional<Reminder> inviteReminder = inviteReminderModel.getReminder();
if (UnauthorizedReminder.isEligible(this)) {
reminderView.get().showReminder(new UnauthorizedReminder(this));
@@ -1439,21 +1444,31 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
reminderView.get().showReminder(new ServiceOutageReminder(this));
} else if (TextSecurePreferences.isPushRegistered(this) &&
TextSecurePreferences.isShowInviteReminders(this) &&
!isSecureText &&
!seenInvite &&
!recipient.get().isGroup())
{
InviteReminder reminder = new InviteReminder(this, recipient.get());
reminder.setOkListener(v -> {
handleInviteLink();
reminderView.get().requestDismiss();
});
reminderView.get().showReminder(reminder);
!isSecureText &&
inviteReminder.isPresent() &&
!recipient.get().isGroup()) {
reminderView.get().setOnActionClickListener(this::handleReminderAction);
reminderView.get().setOnDismissListener(() -> inviteReminderModel.dismissReminder());
reminderView.get().showReminder(inviteReminder.get());
} else if (reminderView.resolved()) {
reminderView.get().hide();
}
}
private void handleReminderAction(@IdRes int reminderActionId) {
switch (reminderActionId) {
case R.id.reminder_action_invite:
handleInviteLink();
reminderView.get().requestDismiss();
break;
case R.id.reminder_action_view_insights:
InsightsLauncher.showInsightsDashboard(getSupportFragmentManager());
break;
default:
throw new IllegalArgumentException("Unknown ID: " + reminderActionId);
}
}
private void updateDefaultSubscriptionId(Optional<Integer> defaultSubscriptionId) {
Log.i(TAG, "updateDefaultSubscriptionId(" + defaultSubscriptionId.orNull() + ")");
sendButton.setDefaultSubscriptionId(defaultSubscriptionId);
@@ -1742,7 +1757,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
setBlockedUserState(recipient, isSecureText, isDefaultSms);
setActionBarColor(recipient.getColor());
setGroupShareProfileReminder(recipient);
updateReminders(recipient.hasSeenInviteReminder());
updateReminders();
updateDefaultSubscriptionId(recipient.getDefaultSubscriptionId());
initializeSecurity(isSecureText, isDefaultSms);

View File

@@ -116,7 +116,7 @@ public class ConversationPopupActivity extends ConversationActivity {
}
@Override
protected void updateReminders(boolean seenInvite) {
protected void updateReminders() {
if (reminderView.resolved()) {
reminderView.get().setVisibility(View.GONE);
}

View File

@@ -7,6 +7,8 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.documents.Document;
@@ -16,12 +18,14 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.IdentityKey;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeUnit;
public abstract class MessagingDatabase extends Database implements MmsSmsColumns {
@@ -32,6 +36,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
}
protected abstract String getTableName();
protected abstract String getTypeField();
protected abstract String getDateSentColumnName();
public abstract void markExpireStarted(long messageId);
public abstract void markExpireStarted(long messageId, long startTime);
@@ -39,6 +45,61 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void markAsSent(long messageId, boolean secure);
public abstract void markUnidentified(long messageId, boolean unidentified);
final int getInsecureMessagesSentForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] projection = new String[]{"COUNT(*)"};
String query = THREAD_ID + " = ? AND " + getOutgoingInsecureMessageClause() + " AND " + getDateSentColumnName() + " > ?";
String[] args = new String[]{String.valueOf(threadId), String.valueOf(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))};
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} else {
return 0;
}
}
}
final int getInsecureMessageCountForRecipients(List<RecipientId> recipients) {
return getMessageCountForRecipientsAndType(recipients, getOutgoingInsecureMessageClause());
}
final int getSecureMessageCountForRecipients(List<RecipientId> recipients) {
return getMessageCountForRecipientsAndType(recipients, getOutgoingSecureMessageClause());
}
private int getMessageCountForRecipientsAndType(List<RecipientId> recipients, String typeClause) {
if (recipients.size() == 0) return 0;
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String placeholders = Util.join(Stream.of(recipients).map(r -> "?").toList(), ",");
String[] projection = new String[] {"COUNT(*)"};
String query = RECIPIENT_ID + " IN ( " + placeholders + " ) AND " + typeClause + " AND " + getDateSentColumnName() + " > ?";
String[] args = new String[recipients.size() + 1];
for (int i = 0; i < recipients.size(); i++) {
args[i] = recipients.get(i).serialize();
}
args[args.length - 1] = String.valueOf(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7));
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} else {
return 0;
}
}
}
private String getOutgoingInsecureMessageClause() {
return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + getTypeField() + " & " + Types.SECURE_MESSAGE_BIT + ")";
}
private String getOutgoingSecureMessageClause() {
return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
}
public void addMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) {
try {
addToDocument(messageId, MISMATCHED_IDENTITIES,

View File

@@ -35,7 +35,6 @@ import net.sqlcipher.database.SQLiteDatabase;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
@@ -81,6 +80,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import static org.thoughtcrime.securesms.contactshare.Contact.Avatar;
@@ -182,6 +182,9 @@ public class MmsDatabase extends MessagingDatabase {
private static final String RAW_ID_WHERE = TABLE_NAME + "._id = ?";
private static final String OUTGOING_INSECURE_MESSAGES_CLAUSE = "(" + MESSAGE_BOX + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + MESSAGE_BOX + " & " + Types.SECURE_MESSAGE_BIT + ")";
private static final String OUTGOING_SECURE_MESSAGES_CLAUSE = "(" + MESSAGE_BOX + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + MESSAGE_BOX + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
private final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache("MmsDelivery");
private final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache("MmsRead");
@@ -194,6 +197,16 @@ public class MmsDatabase extends MessagingDatabase {
return TABLE_NAME;
}
@Override
protected String getDateSentColumnName() {
return DATE_SENT;
}
@Override
protected String getTypeField() {
return MESSAGE_BOX;
}
public int getMessageCountForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null;

View File

@@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class MmsSmsDatabase extends Database {
@@ -155,6 +156,27 @@ public class MmsSmsDatabase extends Database {
return count;
}
public int getInsecureSentCount(long threadId) {
int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessagesSentForThread(threadId);
count += DatabaseFactory.getMmsDatabase(context).getInsecureMessagesSentForThread(threadId);
return count;
}
public int getInsecureMessageCountForRecipients(List<RecipientId> recipients) {
int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessageCountForRecipients(recipients);
count += DatabaseFactory.getMmsDatabase(context).getInsecureMessageCountForRecipients(recipients);
return count;
}
public int getSecureMessageCountForRecipients(List<RecipientId> recipients) {
int count = DatabaseFactory.getSmsDatabase(context).getSecureMessageCountForRecipients(recipients);
count += DatabaseFactory.getMmsDatabase(context).getSecureMessageCountForRecipients(recipients);
return count;
}
public void incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) {
DatabaseFactory.getSmsDatabase(context).incrementReceiptCount(syncMessageId, true, false);
DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, true, false);

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.database;
import android.annotation.SuppressLint;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
@@ -140,6 +141,28 @@ public class RecipientDatabase extends Database {
}
}
public enum InsightsBannerTier {
NO_TIER(0), TIER_ONE(1), TIER_TWO(2);
private final int id;
InsightsBannerTier(int id) {
this.id = id;
}
public int getId() {
return id;
}
public boolean seen(InsightsBannerTier tier) {
return tier.getId() <= id;
}
public static InsightsBannerTier fromId(int id) {
return values()[id];
}
}
public static final String CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
UUID + " TEXT UNIQUE DEFAULT NULL, " +
@@ -154,7 +177,7 @@ public class RecipientDatabase extends Database {
NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " +
MUTE_UNTIL + " INTEGER DEFAULT 0, " +
COLOR + " TEXT DEFAULT NULL, " +
SEEN_INVITE_REMINDER + " INTEGER DEFAULT 0, " +
SEEN_INVITE_REMINDER + " INTEGER DEFAULT " + InsightsBannerTier.NO_TIER.getId() + ", " +
DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
MESSAGE_EXPIRATION_TIME + " INTEGER DEFAULT 0, " +
REGISTERED + " INTEGER DEFAULT " + RegisteredState.UNKNOWN.getId() + ", " +
@@ -171,6 +194,17 @@ public class RecipientDatabase extends Database {
FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " +
UUID_SUPPORTED + " INTEGER DEFAULT 0);";
private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID +
" FROM " + TABLE_NAME +
" INNER JOIN " + ThreadDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID +
" WHERE " +
TABLE_NAME + "." + GROUP_ID + " IS NULL AND " +
TABLE_NAME + "." + REGISTERED + " = " + RegisteredState.NOT_REGISTERED.id + " AND " +
TABLE_NAME + "." + SEEN_INVITE_REMINDER + " < " + InsightsBannerTier.TIER_TWO.id + " AND " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.HAS_SENT +
" ORDER BY " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " DESC";
public RecipientDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
@@ -264,7 +298,7 @@ public class RecipientDatabase extends Database {
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
boolean seenInviteReminder = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_INVITE_REMINDER)) == 1;
int insightsBannerTier = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_INVITE_REMINDER));
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(MESSAGE_EXPIRATION_TIME));
int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
@@ -304,14 +338,13 @@ public class RecipientDatabase extends Database {
VibrateState.fromId(messageVibrateState),
VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone),
color, seenInviteReminder,
defaultSubscriptionId, expireMessages,
color, defaultSubscriptionId, expireMessages,
RegisteredState.fromId(registeredState),
profileKey, systemDisplayName, systemContactPhoto,
systemPhoneLabel, systemContactUri,
signalProfileName, signalProfileAvatar, profileSharing,
notificationChannel, UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
forceSmsSelection, uuidSupported);
forceSmsSelection, uuidSupported, InsightsBannerTier.fromId(insightsBannerTier));
}
public BulkOperationsHandle resetAllSystemContactInfo() {
@@ -392,10 +425,26 @@ public class RecipientDatabase extends Database {
Recipient.live(id).refresh();
}
public void setSeenInviteReminder(@NonNull RecipientId id, @SuppressWarnings("SameParameterValue") boolean seen) {
ContentValues values = new ContentValues(1);
values.put(SEEN_INVITE_REMINDER, seen ? 1 : 0);
update(id, values);
public void setSeenFirstInviteReminder(@NonNull RecipientId id) {
setInsightsBannerTier(id, InsightsBannerTier.TIER_ONE);
}
public void setSeenSecondInviteReminder(@NonNull RecipientId id) {
setInsightsBannerTier(id, InsightsBannerTier.TIER_TWO);
}
public void setHasSentInvite(@NonNull RecipientId id) {
setSeenSecondInviteReminder(id);
}
private void setInsightsBannerTier(@NonNull RecipientId id, @NonNull InsightsBannerTier insightsBannerTier) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues(1);
String query = ID + " = ? AND " + SEEN_INVITE_REMINDER + " < ?";
String[] args = new String[]{ id.serialize(), String.valueOf(insightsBannerTier) };
values.put(SEEN_INVITE_REMINDER, insightsBannerTier.id);
database.update(TABLE_NAME, values, query, args);
Recipient.live(id).refresh();
}
@@ -563,7 +612,45 @@ public class RecipientDatabase extends Database {
}
}
public List<RecipientId> getRegistered() {
public @NonNull List<RecipientId> getUninvitedRecipientsForInsights() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<RecipientId> results = new LinkedList<>();
try (Cursor cursor = db.rawQuery(INSIGHTS_INVITEE_LIST, null)) {
while (cursor != null && cursor.moveToNext()) {
results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))));
}
}
return results;
}
public @NonNull List<RecipientId> getNotRegisteredForInsights() {
return getRecipientsForInsights(REGISTERED + " = ?", new String[]{String.valueOf(RegisteredState.NOT_REGISTERED.id)});
}
public @NonNull List<RecipientId> getRegisteredForInsights() {
final String selfId = Recipient.self().getId().serialize();
final String query = REGISTERED + " = ? AND " + ID + " != ?";
final String[] args = new String[]{String.valueOf(RegisteredState.REGISTERED.id), selfId};
return getRecipientsForInsights(query, args);
}
private @NonNull List<RecipientId> getRecipientsForInsights(@NonNull String query, @NonNull String[] args) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<RecipientId> results = new LinkedList<>();
try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, query + " AND " + GROUP_ID + " IS NULL", args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))));
}
}
return results;
}
public @NonNull List<RecipientId> getRegistered() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<RecipientId> results = new LinkedList<>();
@@ -776,7 +863,6 @@ public class RecipientDatabase extends Database {
private final Uri messageRingtone;
private final Uri callRingtone;
private final MaterialColor color;
private final boolean seenInviteReminder;
private final int defaultSubscriptionId;
private final int expireMessages;
private final RegisteredState registered;
@@ -792,6 +878,7 @@ public class RecipientDatabase extends Database {
private final UnidentifiedAccessMode unidentifiedAccessMode;
private final boolean forceSmsSelection;
private final boolean uuidSupported;
private final InsightsBannerTier insightsBannerTier;
RecipientSettings(@NonNull RecipientId id,
@Nullable UUID uuid,
@@ -804,7 +891,6 @@ public class RecipientDatabase extends Database {
@Nullable Uri messageRingtone,
@Nullable Uri callRingtone,
@Nullable MaterialColor color,
boolean seenInviteReminder,
int defaultSubscriptionId,
int expireMessages,
@NonNull RegisteredState registered,
@@ -819,7 +905,8 @@ public class RecipientDatabase extends Database {
@Nullable String notificationChannel,
@NonNull UnidentifiedAccessMode unidentifiedAccessMode,
boolean forceSmsSelection,
boolean uuidSupported)
boolean uuidSupported,
@NonNull InsightsBannerTier insightsBannerTier)
{
this.id = id;
this.uuid = uuid;
@@ -833,7 +920,6 @@ public class RecipientDatabase extends Database {
this.messageRingtone = messageRingtone;
this.callRingtone = callRingtone;
this.color = color;
this.seenInviteReminder = seenInviteReminder;
this.defaultSubscriptionId = defaultSubscriptionId;
this.expireMessages = expireMessages;
this.registered = registered;
@@ -849,6 +935,7 @@ public class RecipientDatabase extends Database {
this.unidentifiedAccessMode = unidentifiedAccessMode;
this.forceSmsSelection = forceSmsSelection;
this.uuidSupported = uuidSupported;
this.insightsBannerTier = insightsBannerTier;
}
public RecipientId getId() {
@@ -899,8 +986,8 @@ public class RecipientDatabase extends Database {
return callRingtone;
}
public boolean hasSeenInviteReminder() {
return seenInviteReminder;
public @NonNull InsightsBannerTier getInsightsBannerTier() {
return insightsBannerTier;
}
public Optional<Integer> getDefaultSubscriptionId() {

View File

@@ -29,7 +29,6 @@ import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteStatement;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@@ -45,6 +44,7 @@ import org.thoughtcrime.securesms.sms.IncomingTextMessage;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
@@ -53,6 +53,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Database for storage of SMS messages.
@@ -102,6 +103,9 @@ public class SmsDatabase extends MessagingDatabase {
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED
};
private final String OUTGOING_INSECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + TYPE + " & " + Types.SECURE_MESSAGE_BIT + ")";
private final String OUTGOING_SECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + TYPE + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache("SmsDelivery");
private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache("SmsRead");
@@ -113,6 +117,16 @@ public class SmsDatabase extends MessagingDatabase {
return TABLE_NAME;
}
@Override
protected String getDateSentColumnName() {
return DATE_SENT;
}
@Override
protected String getTypeField() {
return TYPE;
}
private void updateTypeBitmask(long id, long maskOff, long maskOn) {
Log.i("MessageDatabase", "Updating ID: " + id + " to base type: " + maskOn);

View File

@@ -79,7 +79,7 @@ public class ThreadDatabase extends Database {
public static final String READ_RECEIPT_COUNT = "read_receipt_count";
public static final String EXPIRES_IN = "expires_in";
public static final String LAST_SEEN = "last_seen";
private static final String HAS_SENT = "has_sent";
public static final String HAS_SENT = "has_sent";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" +
ID + " INTEGER PRIMARY KEY, " + DATE + " INTEGER DEFAULT 0, " +

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,269 @@
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__tagline, 100 - insecurePercent));
if (insecurePercent == 0) {
lottieAnimationView.setVisibility(View.VISIBLE);
title.setText(R.string.InsightsDashboardFragment__100_title);
description.setText(R.string.InsightsDashboardFragment__100_description);
} else {
lottieAnimationView.setVisibility(View.GONE);
title.setText(R.string.InsightsDashboardFragment__boost_your_signal);
description.setText(R.string.InsightsDashboardFragment__invite_your_contacts);
}
}
private void setNotEnoughDataText() {
startAConversation.setVisibility(View.VISIBLE);
progressContainer.setVisibility(View.INVISIBLE);
insecureRecipients.setVisibility(View.GONE);
encryptedMessages.setText(R.string.InsightsDashboardFragment__no_signal_yet);
tagline.setText(R.string.InsightsDashboardFragment__youre_just_getting_started);
}
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,19 @@
package org.thoughtcrime.securesms.insights;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
class InsightsOptOut {
private static final String INSIGHTS_OPT_OUT_PREFERENCE = "insights.opt.out";
static boolean userHasOptedOut(@NonNull Context context) {
return TextSecurePreferences.getBooleanPreference(context, INSIGHTS_OPT_OUT_PREFERENCE, false);
}
static void userRequestedOptOut(@NonNull Context context) {
TextSecurePreferences.setBooleanPreference(context, INSIGHTS_OPT_OUT_PREFERENCE, true);
}
}

View File

@@ -0,0 +1,102 @@
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.database.ThreadDatabase;
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.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(() -> {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
List<RecipientId> unregisteredRecipients = recipientDatabase.getNotRegisteredForInsights();
List<RecipientId> registeredRecipients = recipientDatabase.getRegisteredForInsights();
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
int insecure = mmsSmsDatabase.getInsecureMessageCountForRecipients(unregisteredRecipients);
int secure = mmsSmsDatabase.getSecureMessageCountForRecipients(registeredRecipients);
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);
}
}

View File

@@ -0,0 +1,144 @@
package org.thoughtcrime.securesms.invites;
import android.content.Context;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.components.reminder.SecondInviteReminder;
import org.thoughtcrime.securesms.components.reminder.FirstInviteReminder;
import org.thoughtcrime.securesms.components.reminder.Reminder;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.concurrent.atomic.AtomicReference;
public final class InviteReminderModel {
private static final int FIRST_INVITE_REMINDER_MESSAGE_THRESHOLD = 10;
private static final int SECOND_INVITE_REMINDER_MESSAGE_THRESHOLD = 500;
private final Context context;
private final Repository repository;
private final AtomicReference<ReminderInfo> reminderInfo = new AtomicReference<>();
public InviteReminderModel(@NonNull Context context, @NonNull Repository repository) {
this.context = context;
this.repository = repository;
}
@MainThread
public void loadReminder(LiveRecipient liveRecipient, Runnable reminderCheckComplete) {
SimpleTask.run(() -> createReminderInfo(liveRecipient.resolve()), result -> {
reminderInfo.set(result);
reminderCheckComplete.run();
});
}
@WorkerThread
private @NonNull ReminderInfo createReminderInfo(Recipient recipient) {
Recipient resolved = recipient.resolve();
if (resolved.isRegistered() || resolved.isGroup() || resolved.hasSeenSecondInviteReminder()) {
return new NoReminderInfo();
}
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
long threadId = threadDatabase.getThreadIdFor(recipient);
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
int conversationCount = mmsSmsDatabase.getInsecureSentCount(threadId);
if (conversationCount >= SECOND_INVITE_REMINDER_MESSAGE_THRESHOLD && !resolved.hasSeenSecondInviteReminder()) {
return new SecondInviteReminderInfo(context, resolved, repository, repository.getPercentOfInsecureMessages(conversationCount));
} else if (conversationCount >= FIRST_INVITE_REMINDER_MESSAGE_THRESHOLD && !resolved.hasSeenFirstInviteReminder()) {
return new FirstInviteReminderInfo(context, resolved, repository, repository.getPercentOfInsecureMessages(conversationCount));
} else {
return new NoReminderInfo();
}
}
public @NonNull Optional<Reminder> getReminder() {
ReminderInfo info = reminderInfo.get();
if (info == null) return Optional.absent();
else return Optional.fromNullable(info.reminder);
}
public void dismissReminder() {
final ReminderInfo info = reminderInfo.getAndSet(null);
SimpleTask.run(() -> {
info.dismiss();
return null;
}, (v) -> {});
}
interface Repository {
void setHasSeenFirstInviteReminder(Recipient recipient);
void setHasSeenSecondInviteReminder(Recipient recipient);
int getPercentOfInsecureMessages(int insecureCount);
}
private static abstract class ReminderInfo {
private final Reminder reminder;
ReminderInfo(Reminder reminder) {
this.reminder = reminder;
}
@WorkerThread
void dismiss() {
}
}
private static class NoReminderInfo extends ReminderInfo {
private NoReminderInfo() {
super(null);
}
}
private class FirstInviteReminderInfo extends ReminderInfo {
private final Repository repository;
private final Recipient recipient;
private FirstInviteReminderInfo(@NonNull Context context, @NonNull Recipient recipient, @NonNull Repository repository, int percentInsecure) {
super(new FirstInviteReminder(context, recipient, percentInsecure));
this.recipient = recipient;
this.repository = repository;
}
@Override
@WorkerThread
void dismiss() {
repository.setHasSeenFirstInviteReminder(recipient);
}
}
private static class SecondInviteReminderInfo extends ReminderInfo {
private final Repository repository;
private final Recipient recipient;
private SecondInviteReminderInfo(@NonNull Context context, @NonNull Recipient recipient, @NonNull Repository repository, int percentInsecure) {
super(new SecondInviteReminder(context, recipient, percentInsecure));
this.repository = repository;
this.recipient = recipient;
}
@Override
@WorkerThread
void dismiss() {
repository.setHasSeenSecondInviteReminder(recipient);
}
}
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.invites;
import android.content.Context;
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 java.util.List;
public final class InviteReminderRepository implements InviteReminderModel.Repository {
private final Context context;
public InviteReminderRepository(Context context) {
this.context = context;
}
@Override
public void setHasSeenFirstInviteReminder(Recipient recipient) {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
recipientDatabase.setSeenFirstInviteReminder(recipient.getId());
}
@Override
public void setHasSeenSecondInviteReminder(Recipient recipient) {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
recipientDatabase.setSeenSecondInviteReminder(recipient.getId());
}
@Override
public int getPercentOfInsecureMessages(int insecureCount) {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
List<RecipientId> registeredRecipients = recipientDatabase.getRegisteredForInsights();
List<RecipientId> unregisteredRecipients = recipientDatabase.getNotRegisteredForInsights();
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
int insecure = mmsSmsDatabase.getInsecureMessageCountForRecipients(unregisteredRecipients);
int secure = mmsSmsDatabase.getSecureMessageCountForRecipients(registeredRecipients);
if (insecure + secure == 0) return 0;
return Math.round(100f * (insecureCount / (float) (insecure + secure)));
}
}

View File

@@ -47,6 +47,8 @@ import java.util.List;
import java.util.Objects;
import java.util.UUID;
import static org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier;
public class Recipient {
public static final Recipient UNKNOWN = new Recipient(RecipientId.UNKNOWN, new RecipientDetails());
@@ -69,7 +71,6 @@ public class Recipient {
private final Uri messageRingtone;
private final Uri callRingtone;
private final MaterialColor color;
private final boolean seenInviteReminder;
private final Optional<Integer> defaultSubscriptionId;
private final int expireMessages;
private final RegisteredState registered;
@@ -85,6 +86,7 @@ public class Recipient {
private final UnidentifiedAccessMode unidentifiedAccessMode;
private final boolean forceSmsSelection;
private final boolean uuidSupported;
private final InsightsBannerTier insightsBannerTier;
/**
@@ -246,7 +248,7 @@ public class Recipient {
this.messageRingtone = null;
this.callRingtone = null;
this.color = null;
this.seenInviteReminder = true;
this.insightsBannerTier = InsightsBannerTier.TIER_TWO;
this.defaultSubscriptionId = Optional.absent();
this.expireMessages = 0;
this.registered = RegisteredState.UNKNOWN;
@@ -281,7 +283,7 @@ public class Recipient {
this.messageRingtone = details.messageRingtone;
this.callRingtone = details.callRingtone;
this.color = details.color;
this.seenInviteReminder = details.seenInviteReminder;
this.insightsBannerTier = details.insightsBannerTier;
this.defaultSubscriptionId = details.defaultSubscriptionId;
this.expireMessages = details.expireMessages;
this.registered = details.registered;
@@ -571,8 +573,12 @@ public class Recipient {
return expireMessages;
}
public boolean hasSeenInviteReminder() {
return seenInviteReminder;
public boolean hasSeenFirstInviteReminder() {
return insightsBannerTier.seen(InsightsBannerTier.TIER_ONE);
}
public boolean hasSeenSecondInviteReminder() {
return insightsBannerTier.seen(InsightsBannerTier.TIER_TWO);
}
public @NonNull RegisteredState getRegistered() {

View File

@@ -7,6 +7,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier;
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode;
@@ -40,7 +41,6 @@ public class RecipientDetails {
final int expireMessages;
final List<Recipient> participants;
final String profileName;
final boolean seenInviteReminder;
final Optional<Integer> defaultSubscriptionId;
final RegisteredState registered;
final byte[] profileKey;
@@ -52,6 +52,7 @@ public class RecipientDetails {
final UnidentifiedAccessMode unidentifiedAccessMode;
final boolean forceSmsSelection;
final boolean uuidSuported;
final InsightsBannerTier insightsBannerTier;
RecipientDetails(@NonNull Context context,
@Nullable String name,
@@ -79,7 +80,6 @@ public class RecipientDetails {
this.expireMessages = settings.getExpireMessages();
this.participants = participants == null ? new LinkedList<>() : participants;
this.profileName = isLocalNumber ? TextSecurePreferences.getProfileName(context) : settings.getProfileName();
this.seenInviteReminder = settings.hasSeenInviteReminder();
this.defaultSubscriptionId = settings.getDefaultSubscriptionId();
this.registered = settings.getRegistered();
this.profileKey = settings.getProfileKey();
@@ -91,6 +91,7 @@ public class RecipientDetails {
this.unidentifiedAccessMode = settings.getUnidentifiedAccessMode();
this.forceSmsSelection = settings.isForceSmsSelection();
this.uuidSuported = settings.isUuidSupported();
this.insightsBannerTier = settings.getInsightsBannerTier();
if (name == null) this.name = settings.getSystemDisplayName();
else this.name = name;
@@ -115,7 +116,7 @@ public class RecipientDetails {
this.expireMessages = 0;
this.participants = new LinkedList<>();
this.profileName = null;
this.seenInviteReminder = true;
this.insightsBannerTier = InsightsBannerTier.TIER_TWO;
this.defaultSubscriptionId = Optional.absent();
this.registered = RegisteredState.UNKNOWN;
this.profileKey = null;