Add support for creating Megaphones. Includes reactions megaphone.

This commit is contained in:
Greyson Parrelli
2020-01-22 09:22:19 -05:00
parent ef4c7e96da
commit 22f9bfeceb
29 changed files with 1195 additions and 45 deletions

View File

@@ -0,0 +1,99 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
public class BasicMegaphoneView extends FrameLayout {
private ImageView image;
private TextView titleText;
private TextView bodyText;
private Button actionButton;
private Button snoozeButton;
private Megaphone megaphone;
private MegaphoneListener megaphoneListener;
public BasicMegaphoneView(@NonNull Context context) {
super(context);
init(context);
}
public BasicMegaphoneView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(@NonNull Context context) {
inflate(context, R.layout.basic_megaphone_view, this);
this.image = findViewById(R.id.basic_megaphone_image);
this.titleText = findViewById(R.id.basic_megaphone_title);
this.bodyText = findViewById(R.id.basic_megaphone_body);
this.actionButton = findViewById(R.id.basic_megaphone_action);
this.snoozeButton = findViewById(R.id.basic_megaphone_snooze);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (megaphone != null && megaphoneListener != null && megaphone.getOnVisibleListener() != null) {
megaphone.getOnVisibleListener().onVisible(megaphone, megaphoneListener);
}
}
public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneListener megaphoneListener) {
this.megaphone = megaphone;
this.megaphoneListener = megaphoneListener;
if (megaphone.getImage() != 0) {
image.setVisibility(VISIBLE);
image.setImageResource(megaphone.getImage());
} else {
image.setVisibility(GONE);
}
if (megaphone.getTitle() != 0) {
titleText.setVisibility(VISIBLE);
titleText.setText(megaphone.getTitle());
} else {
titleText.setVisibility(GONE);
}
if (megaphone.getBody() != 0) {
bodyText.setVisibility(VISIBLE);
bodyText.setText(megaphone.getBody());
} else {
bodyText.setVisibility(GONE);
}
if (megaphone.getButtonText() != 0) {
actionButton.setVisibility(VISIBLE);
actionButton.setText(megaphone.getButtonText());
actionButton.setOnClickListener(v -> {
if (megaphone.getButtonClickListener() != null) {
megaphone.getButtonClickListener().onClick(megaphone, megaphoneListener);
}
});
} else {
actionButton.setVisibility(GONE);
}
if (megaphone.canSnooze()) {
snoozeButton.setVisibility(VISIBLE);
snoozeButton.setOnClickListener(v -> megaphoneListener.onMegaphoneSnooze(megaphone));
} else {
actionButton.setVisibility(GONE);
}
}
}

View File

@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.megaphone;
final class ForeverSchedule implements MegaphoneSchedule {
private final boolean enabled;
ForeverSchedule(boolean enabled) {
this.enabled = enabled;
}
@Override
public boolean shouldDisplay(int seenCount, long lastSeen, long currentTime) {
return enabled;
}
}

View File

@@ -0,0 +1,167 @@
package org.thoughtcrime.securesms.megaphone;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
/**
* For guidance on creating megaphones, see {@link Megaphones}.
*/
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 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.onVisibleListener = builder.onVisibleListener;
}
public @NonNull Event getEvent() {
return event;
}
public boolean isMandatory() {
return mandatory;
}
public int getMaxAppearances() {
return maxAppearances;
}
public boolean canSnooze() {
return canSnooze;
}
public @NonNull Style getStyle() {
return style;
}
public @StringRes int getTitle() {
return titleRes;
}
public @StringRes int getBody() {
return bodyRes;
}
public @DrawableRes int getImage() {
return imageRes;
}
public @StringRes int getButtonText() {
return buttonTextRes;
}
public @Nullable OnClickListener getButtonClickListener() {
return buttonListener;
}
public @Nullable OnVisibleListener getOnVisibleListener() {
return onVisibleListener;
}
public static class Builder {
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;
public Builder(@NonNull Event event, @NonNull Style style) {
this.event = event;
this.style = style;
this.maxAppearances = 1;
}
public @NonNull Builder setMandatory(boolean mandatory) {
this.mandatory = mandatory;
return this;
}
public @NonNull Builder setSnooze(boolean canSnooze) {
this.canSnooze = canSnooze;
return this;
}
public @NonNull Builder setMaxAppearances(int maxAppearances) {
this.maxAppearances = maxAppearances;
return this;
}
public @NonNull Builder setTitle(@StringRes int titleRes) {
this.titleRes = titleRes;
return this;
}
public @NonNull Builder setBody(@StringRes int bodyRes) {
this.bodyRes = bodyRes;
return this;
}
public @NonNull Builder setImage(@DrawableRes int imageRes) {
this.imageRes = imageRes;
return this;
}
public @NonNull Builder setButtonText(@StringRes int buttonTextRes, @NonNull OnClickListener listener) {
this.buttonTextRes = buttonTextRes;
this.buttonListener = listener;
return this;
}
public @NonNull Builder setOnVisibleListener(@Nullable OnVisibleListener listener) {
this.onVisibleListener = listener;
return this;
}
public @NonNull Megaphone build() {
return new Megaphone(this);
}
}
enum Style {
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);
}
}

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
public interface MegaphoneListener {
/**
* When a megaphone wants to navigate to a specific intent.
*/
void onMegaphoneNavigationRequested(@NonNull Intent intent);
/**
* When a megaphone wants to show a toast/snackbar.
*/
void onMegaphoneToastRequested(@StringRes int stringRes);
/**
* When a megaphone has been snoozed via "remind me later" or a similar option.
*/
void onMegaphoneSnooze(@NonNull Megaphone megaphone);
/**
* Called when a megaphone completed its goal.
*/
void onMegaphoneCompleted(@NonNull Megaphone megaphone);
}

View File

@@ -0,0 +1,126 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MegaphoneDatabase;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
/**
* Synchronization of data structures is done using a serial executor. Do not access or change
* data structures or fields on anything except the executor.
*/
public class MegaphoneRepository {
private final Context context;
private final Executor executor;
private final MegaphoneDatabase database;
private final Map<Event, MegaphoneRecord> databaseCache;
private boolean enabled;
public MegaphoneRepository(@NonNull Context context) {
this.context = context;
this.executor = SignalExecutors.SERIAL;
this.database = DatabaseFactory.getMegaphoneDatabase(context);
this.databaseCache = new HashMap<>();
executor.execute(this::init);
}
/**
* Marks any megaphones a new user shouldn't see as "finished".
*/
@MainThread
public void onFirstEverAppLaunch() {
executor.execute(() -> {
// Future megaphones we don't want to show to new users should get marked as finished here.
});
}
@MainThread
public void onAppForegrounded() {
executor.execute(() -> enabled = true);
}
@MainThread
public void getNextMegaphone(@NonNull Callback<Megaphone> callback) {
executor.execute(() -> {
if (enabled) {
callback.onResult(Megaphones.getNextMegaphone(context, databaseCache));
} else {
callback.onResult(null);
}
});
}
@MainThread
public void markSeen(@NonNull Megaphone megaphone) {
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);
}
enabled = false;
resetDatabaseCache();
});
}
@MainThread
public void markFinished(@NonNull Event event) {
executor.execute(() -> {
database.markFinished(event);
resetDatabaseCache();
});
}
@WorkerThread
private void init() {
List<MegaphoneRecord> records = database.getAll();
Set<Event> events = Stream.of(records).map(MegaphoneRecord::getEvent).collect(Collectors.toSet());
Set<Event> missing = Stream.of(Megaphones.Event.values()).filterNot(events::contains).collect(Collectors.toSet());
database.insert(missing);
resetDatabaseCache();
}
@WorkerThread
private @NonNull MegaphoneRecord getRecord(@NonNull Event event) {
//noinspection ConstantConditions
return databaseCache.get(event);
}
@WorkerThread
private void resetDatabaseCache() {
databaseCache.clear();
databaseCache.putAll(Stream.of(database.getAll()).collect(Collectors.toMap(MegaphoneRecord::getEvent, m -> m)));
}
public interface Callback<E> {
void onResult(E result);
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.megaphone;
public interface MegaphoneSchedule {
boolean shouldDisplay(int seenCount, long lastSeen, long currentTime);
}

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.reactions.ReactionsMegaphoneView;
public class MegaphoneViewBuilder {
public static @Nullable View build(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneListener listener)
{
switch (megaphone.getStyle()) {
case BASIC:
return buildBasicMegaphone(context, megaphone, listener);
case FULLSCREEN:
return null;
case REACTIONS:
return buildReactionsMegaphone(context, megaphone, listener);
default:
throw new IllegalArgumentException("No view implemented for style!");
}
}
private static @NonNull View buildBasicMegaphone(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneListener listener)
{
BasicMegaphoneView view = new BasicMegaphoneView(context);
view.present(megaphone, listener);
return view;
}
private static @NonNull View buildReactionsMegaphone(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneListener listener)
{
ReactionsMegaphoneView view = new ReactionsMegaphoneView(context);
view.present(megaphone, listener);
return view;
}
}

View File

@@ -0,0 +1,117 @@
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;
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.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)}
* - 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)}
* based on whatever properties you're interested in.
*/
public final class Megaphones {
private Megaphones() {}
static @Nullable Megaphone getNextMegaphone(@NonNull Context context, @NonNull Map<Event, MegaphoneRecord> records) {
long currentTime = System.currentTimeMillis();
List<Megaphone> megaphones = Stream.of(buildDisplayOrder())
.filter(e -> {
MegaphoneRecord record = Objects.requireNonNull(records.get(e.getKey()));
MegaphoneSchedule schedule = e.getValue();
return !record.isFinished() && schedule.shouldDisplay(record.getSeenCount(), record.getLastSeen(), currentTime);
})
.map(Map.Entry::getKey)
.map(records::get)
.map(Megaphones::forRecord)
.toList();
boolean hasOptional = Stream.of(megaphones).anyMatch(m -> !m.isMandatory());
boolean hasMandatory = Stream.of(megaphones).anyMatch(Megaphone::isMandatory);
if (hasOptional && hasMandatory) {
megaphones = Stream.of(megaphones).filter(Megaphone::isMandatory).toList();
}
if (megaphones.size() > 0) {
return megaphones.get(0);
} else {
return null;
}
}
/**
* This is when you would hide certain megaphones based on {@link FeatureFlags}. You could
* conditionally set a {@link ForeverSchedule} set to false for disabled features.
*/
private static Map<Event, MegaphoneSchedule> buildDisplayOrder() {
return new LinkedHashMap<Event, MegaphoneSchedule>() {{
put(Event.REACTIONS, new ForeverSchedule(FeatureFlags.reactionSending()));
}};
}
private static @NonNull Megaphone forRecord(@NonNull MegaphoneRecord record) {
switch (record.getEvent()) {
case REACTIONS:
return buildReactionsMegaphone();
default:
throw new IllegalArgumentException("Event not handled!");
}
}
private static @NonNull Megaphone buildReactionsMegaphone() {
return new Megaphone.Builder(Event.REACTIONS, Megaphone.Style.REACTIONS)
.setMaxAppearances(Megaphone.UNLIMITED)
.setMandatory(false)
.build();
}
public enum Event {
REACTIONS("reactions");
private final String key;
Event(@NonNull String key) {
this.key = key;
}
public @NonNull String getKey() {
return key;
}
public static Event fromKey(@NonNull String key) {
for (Event event : values()) {
if (event.getKey().equals(key)) {
return event;
}
}
throw new IllegalArgumentException("No event for key: " + key);
}
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.megaphone;
class RecurringSchedule implements MegaphoneSchedule {
private final long[] gaps;
RecurringSchedule(long... durationGaps) {
this.gaps = durationGaps;
}
@Override
public boolean shouldDisplay(int seenCount, long lastSeen, long currentTime) {
if (seenCount == 0) {
return true;
}
long gap = gaps[Math.min(seenCount - 1, gaps.length - 1)];
return lastSeen + gap <= currentTime ;
}
}