Implement new PIN UX.

This commit is contained in:
Alex Hart
2020-01-30 16:23:29 -04:00
parent 109d67956f
commit fb82420376
71 changed files with 3000 additions and 203 deletions

View File

@@ -48,7 +48,7 @@ public class BasicMegaphoneView extends FrameLayout {
super.onAttachedToWindow();
if (megaphone != null && megaphoneListener != null && megaphone.getOnVisibleListener() != null) {
megaphone.getOnVisibleListener().onVisible(megaphone, megaphoneListener);
megaphone.getOnVisibleListener().onEvent(megaphone, megaphoneListener);
}
}
@@ -82,7 +82,7 @@ public class BasicMegaphoneView extends FrameLayout {
actionButton.setText(megaphone.getButtonText());
actionButton.setOnClickListener(v -> {
if (megaphone.getButtonClickListener() != null) {
megaphone.getButtonClickListener().onClick(megaphone, megaphoneListener);
megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener);
}
});
} else {
@@ -91,7 +91,13 @@ public class BasicMegaphoneView extends FrameLayout {
if (megaphone.canSnooze()) {
snoozeButton.setVisibility(VISIBLE);
snoozeButton.setOnClickListener(v -> megaphoneListener.onMegaphoneSnooze(megaphone));
snoozeButton.setOnClickListener(v -> {
megaphoneListener.onMegaphoneSnooze(megaphone);
if (megaphone.getSnoozeListener() != null) {
megaphone.getSnoozeListener().onEvent(megaphone, megaphoneListener);
}
});
} else {
actionButton.setVisibility(GONE);
}

View File

@@ -12,32 +12,29 @@ import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
*/
public class Megaphone {
/** For {@link #getMaxAppearances()}. */
public static final int UNLIMITED = -1;
private final Event event;
private final Style style;
private final boolean mandatory;
private final boolean canSnooze;
private final int maxAppearances;
private final int titleRes;
private final int bodyRes;
private final int imageRes;
private final int buttonTextRes;
private final OnClickListener buttonListener;
private final OnVisibleListener onVisibleListener;
private final Event event;
private final Style style;
private final boolean mandatory;
private final boolean canSnooze;
private final int titleRes;
private final int bodyRes;
private final int imageRes;
private final int buttonTextRes;
private final EventListener buttonListener;
private final EventListener snoozeListener;
private final EventListener onVisibleListener;
private Megaphone(@NonNull Builder builder) {
this.event = builder.event;
this.style = builder.style;
this.mandatory = builder.mandatory;
this.canSnooze = builder.canSnooze;
this.maxAppearances = builder.maxAppearances;
this.titleRes = builder.titleRes;
this.bodyRes = builder.bodyRes;
this.imageRes = builder.imageRes;
this.buttonTextRes = builder.buttonTextRes;
this.buttonListener = builder.buttonListener;
this.snoozeListener = builder.snoozeListener;
this.onVisibleListener = builder.onVisibleListener;
}
@@ -49,10 +46,6 @@ public class Megaphone {
return mandatory;
}
public int getMaxAppearances() {
return maxAppearances;
}
public boolean canSnooze() {
return canSnooze;
}
@@ -77,11 +70,15 @@ public class Megaphone {
return buttonTextRes;
}
public @Nullable OnClickListener getButtonClickListener() {
public @Nullable EventListener getButtonClickListener() {
return buttonListener;
}
public @Nullable OnVisibleListener getOnVisibleListener() {
public @Nullable EventListener getSnoozeListener() {
return buttonListener;
}
public @Nullable EventListener getOnVisibleListener() {
return onVisibleListener;
}
@@ -90,21 +87,20 @@ public class Megaphone {
private final Event event;
private final Style style;
private boolean mandatory;
private boolean canSnooze;
private int maxAppearances;
private int titleRes;
private int bodyRes;
private int imageRes;
private int buttonTextRes;
private OnClickListener buttonListener;
private OnVisibleListener onVisibleListener;
private boolean mandatory;
private boolean canSnooze;
private int titleRes;
private int bodyRes;
private int imageRes;
private int buttonTextRes;
private EventListener buttonListener;
private EventListener snoozeListener;
private EventListener onVisibleListener;
public Builder(@NonNull Event event, @NonNull Style style) {
this.event = event;
this.style = style;
this.maxAppearances = 1;
}
public @NonNull Builder setMandatory(boolean mandatory) {
@@ -112,13 +108,15 @@ public class Megaphone {
return this;
}
public @NonNull Builder setSnooze(boolean canSnooze) {
this.canSnooze = canSnooze;
public @NonNull Builder enableSnooze(@Nullable EventListener listener) {
this.canSnooze = true;
this.snoozeListener = listener;
return this;
}
public @NonNull Builder setMaxAppearances(int maxAppearances) {
this.maxAppearances = maxAppearances;
public @NonNull Builder disableSnooze() {
this.canSnooze = false;
this.snoozeListener = null;
return this;
}
@@ -137,13 +135,13 @@ public class Megaphone {
return this;
}
public @NonNull Builder setButtonText(@StringRes int buttonTextRes, @NonNull OnClickListener listener) {
public @NonNull Builder setButtonText(@StringRes int buttonTextRes, @NonNull EventListener listener) {
this.buttonTextRes = buttonTextRes;
this.buttonListener = listener;
return this;
}
public @NonNull Builder setOnVisibleListener(@Nullable OnVisibleListener listener) {
public @NonNull Builder setOnVisibleListener(@Nullable EventListener listener) {
this.onVisibleListener = listener;
return this;
}
@@ -157,11 +155,7 @@ public class Megaphone {
REACTIONS, BASIC, FULLSCREEN
}
public interface OnVisibleListener {
void onVisible(@NonNull Megaphone megaphone, @NonNull MegaphoneListener listener);
}
public interface OnClickListener {
void onClick(@NonNull Megaphone megaphone, @NonNull MegaphoneListener listener);
public interface EventListener {
void onEvent(@NonNull Megaphone megaphone, @NonNull MegaphoneListener listener);
}
}

View File

@@ -11,10 +11,15 @@ public interface MegaphoneListener {
*/
void onMegaphoneNavigationRequested(@NonNull Intent intent);
/**
* When a megaphone wants to navigate to a specific intent for a request code.
*/
void onMegaphoneNavigationRequested(@NonNull Intent intent, int requestCode);
/**
* When a megaphone wants to show a toast/snackbar.
*/
void onMegaphoneToastRequested(@StringRes int stringRes);
void onMegaphoneToastRequested(@NonNull String string);
/**
* When a megaphone has been snoozed via "remind me later" or a similar option.

View File

@@ -82,21 +82,12 @@ public class MegaphoneRepository {
}
@MainThread
public void markSeen(@NonNull Megaphone megaphone) {
public void markSeen(@NonNull Event event) {
long lastSeen = System.currentTimeMillis();
executor.execute(() -> {
Event event = megaphone.getEvent();
MegaphoneRecord record = getRecord(event);
if (megaphone.getMaxAppearances() != Megaphone.UNLIMITED &&
record.getSeenCount() + 1 >= megaphone.getMaxAppearances())
{
database.markFinished(event);
} else {
database.markSeen(event, record.getSeenCount() + 1, lastSeen);
}
database.markSeen(event, record.getSeenCount() + 1, lastSeen);
enabled = false;
resetDatabaseCache();
});

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -11,26 +10,29 @@ import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.lock.v2.KbsMigrationActivity;
import org.thoughtcrime.securesms.lock.v2.PinUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* Creating a new megaphone:
* - Add an enum to {@link Event}
* - Return a megaphone in {@link #forRecord(MegaphoneRecord)}
* - Return a megaphone in {@link #forRecord(Context, MegaphoneRecord)}
* - Include the event in {@link #buildDisplayOrder()}
*
* Common patterns:
* - For events that have a snooze-able recurring display schedule, use a {@link RecurringSchedule}.
* - For events guarded by feature flags, set a {@link ForeverSchedule} with false in
* {@link #buildDisplayOrder()}.
* - For events that change, return different megaphones in {@link #forRecord(MegaphoneRecord)}
* - For events that change, return different megaphones in {@link #forRecord(Context, MegaphoneRecord)}
* based on whatever properties you're interested in.
*/
public final class Megaphones {
@@ -49,7 +51,7 @@ public final class Megaphones {
})
.map(Map.Entry::getKey)
.map(records::get)
.map(Megaphones::forRecord)
.map(record -> Megaphones.forRecord(context, record))
.toList();
boolean hasOptional = Stream.of(megaphones).anyMatch(m -> !m.isMandatory());
@@ -73,13 +75,16 @@ public final class Megaphones {
private static Map<Event, MegaphoneSchedule> buildDisplayOrder() {
return new LinkedHashMap<Event, MegaphoneSchedule>() {{
put(Event.REACTIONS, new ForeverSchedule(FeatureFlags.reactionSending()));
put(Event.PINS_FOR_ALL, new PinsForAllSchedule());
}};
}
private static @NonNull Megaphone forRecord(@NonNull MegaphoneRecord record) {
private static @NonNull Megaphone forRecord(@NonNull Context context, @NonNull MegaphoneRecord record) {
switch (record.getEvent()) {
case REACTIONS:
return buildReactionsMegaphone();
case PINS_FOR_ALL:
return buildPinsForAllMegaphone(context, record);
default:
throw new IllegalArgumentException("Event not handled!");
}
@@ -87,13 +92,65 @@ public final class Megaphones {
private static @NonNull Megaphone buildReactionsMegaphone() {
return new Megaphone.Builder(Event.REACTIONS, Megaphone.Style.REACTIONS)
.setMaxAppearances(Megaphone.UNLIMITED)
.setMandatory(false)
.build();
}
private static @NonNull Megaphone buildPinsForAllMegaphone(@NonNull Context context, @NonNull MegaphoneRecord record) {
if (PinsForAllSchedule.shouldDisplayFullScreen(record.getFirstVisible(), System.currentTimeMillis())) {
return new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.FULLSCREEN)
.setMandatory(true)
.enableSnooze(null)
.setOnVisibleListener((megaphone, listener) -> {
if (new NetworkConstraint.Factory(ApplicationDependencies.getApplication()).create().isMet()) {
listener.onMegaphoneNavigationRequested(KbsMigrationActivity.createIntent(), KbsMigrationActivity.REQUEST_NEW_PIN);
}
})
.build();
} else {
Megaphone.Builder builder = new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.BASIC)
.setMandatory(true)
.setImage(R.drawable.kbs_pin_megaphone);
long daysRemaining = PinsForAllSchedule.getDaysRemaining(record.getFirstVisible(), System.currentTimeMillis());
if (PinUtil.userHasPin(ApplicationDependencies.getApplication())) {
return buildPinsForAllMegaphoneForUserWithPin(
builder.enableSnooze((megaphone, listener) -> listener.onMegaphoneToastRequested(context.getString(R.string.KbsMegaphone__well_remind_you_later_confirming_your_pin, daysRemaining)))
);
} else {
return buildPinsForAllMegaphoneForUserWithoutPin(
builder.enableSnooze((megaphone, listener) -> listener.onMegaphoneToastRequested(context.getString(R.string.KbsMegaphone__well_remind_you_later_creating_a_pin, daysRemaining)))
);
}
}
}
private static @NonNull Megaphone buildPinsForAllMegaphoneForUserWithPin(@NonNull Megaphone.Builder builder) {
return builder.setTitle(R.string.KbsMegaphone__introducing_pins)
.setBody(R.string.KbsMegaphone__your_registration_lock_is_now_called_a_pin)
.setButtonText(R.string.KbsMegaphone__update_pin, (megaphone, listener) -> {
Intent intent = CreateKbsPinActivity.getIntentForPinUpdate(ApplicationDependencies.getApplication());
listener.onMegaphoneNavigationRequested(intent, CreateKbsPinActivity.REQUEST_NEW_PIN);
})
.build();
}
private static @NonNull Megaphone buildPinsForAllMegaphoneForUserWithoutPin(@NonNull Megaphone.Builder builder) {
return builder.setTitle(R.string.KbsMegaphone__create_a_pin)
.setBody(R.string.KbsMegaphone__pins_add_another_layer_of_security_to_your_signal_account)
.setButtonText(R.string.KbsMegaphone__create_pin, (megaphone, listener) -> {
Intent intent = CreateKbsPinActivity.getIntentForPinCreate(ApplicationDependencies.getApplication());
listener.onMegaphoneNavigationRequested(intent, CreateKbsPinActivity.REQUEST_NEW_PIN);
})
.build();
}
public enum Event {
REACTIONS("reactions");
REACTIONS("reactions"),
PINS_FOR_ALL("pins_for_all");
private final String key;

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.megaphone;
import androidx.annotation.VisibleForTesting;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Util;
import java.util.concurrent.TimeUnit;
class PinsForAllSchedule implements MegaphoneSchedule {
@VisibleForTesting
static final long DAYS_UNTIL_FULLSCREEN = 8L;
@VisibleForTesting
static final long DAYS_REMAINING_MAX = DAYS_UNTIL_FULLSCREEN - 1;
private final MegaphoneSchedule schedule = new RecurringSchedule(TimeUnit.DAYS.toMillis(2));
private final boolean enabled = !SignalStore.registrationValues().isPinRequired() || FeatureFlags.pinsForAll();
static boolean shouldDisplayFullScreen(long firstVisible, long currentTime) {
if (firstVisible == 0L) {
return false;
} else {
return currentTime - firstVisible >= TimeUnit.DAYS.toMillis(DAYS_UNTIL_FULLSCREEN);
}
}
static long getDaysRemaining(long firstVisible, long currentTime) {
if (firstVisible == 0L) {
return DAYS_REMAINING_MAX;
} else {
return Util.clamp(DAYS_REMAINING_MAX - TimeUnit.MILLISECONDS.toDays(currentTime - firstVisible), 0, DAYS_REMAINING_MAX);
}
}
@Override
public boolean shouldDisplay(int seenCount, long lastSeen, long firstVisible, long currentTime) {
if (!enabled) return false;
if (shouldDisplayFullScreen(firstVisible, currentTime)) {
return true;
} else {
return schedule.shouldDisplay(seenCount, lastSeen, firstVisible, currentTime);
}
}
}