Add remote megaphone.

This commit is contained in:
Cody Henthorne
2022-05-11 14:33:54 -04:00
committed by Alex Hart
parent 820277800b
commit bb963f9210
20 changed files with 1069 additions and 322 deletions

View File

@@ -71,23 +71,23 @@ public class BasicMegaphoneView extends FrameLayout {
image.setVisibility(GONE);
}
if (megaphone.getTitle() != 0) {
if (megaphone.getTitle().hasText()) {
titleText.setVisibility(VISIBLE);
titleText.setText(megaphone.getTitle());
titleText.setText(megaphone.getTitle().resolve(getContext()));
} else {
titleText.setVisibility(GONE);
}
if (megaphone.getBody() != 0) {
if (megaphone.getBody().hasText()) {
bodyText.setVisibility(VISIBLE);
bodyText.setText(megaphone.getBody());
bodyText.setText(megaphone.getBody().resolve(getContext()));
} else {
bodyText.setVisibility(GONE);
}
if (megaphone.hasButton()) {
actionButton.setVisibility(VISIBLE);
actionButton.setText(megaphone.getButtonText());
actionButton.setText(megaphone.getButtonText().resolve(getContext()));
actionButton.setOnClickListener(v -> {
if (megaphone.getButtonClickListener() != null) {
megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener);
@@ -109,7 +109,7 @@ public class BasicMegaphoneView extends FrameLayout {
}
});
} else {
secondaryButton.setText(megaphone.getSecondaryButtonText());
secondaryButton.setText(megaphone.getSecondaryButtonText().resolve(getContext()));
secondaryButton.setOnClickListener(v -> {
if (megaphone.getSecondaryButtonClickListener() != null) {
megaphone.getSecondaryButtonClickListener().onEvent(megaphone, megaphoneListener);

View File

@@ -20,15 +20,15 @@ public class Megaphone {
private final Event event;
private final Style style;
private final boolean canSnooze;
private final int titleRes;
private final int bodyRes;
private final MegaphoneText titleText;
private final MegaphoneText bodyText;
private final int imageRes;
private final int lottieRes;
private final GlideRequest<Drawable> imageRequest;
private final int buttonTextRes;
private final MegaphoneText buttonText;
private final EventListener buttonListener;
private final EventListener snoozeListener;
private final int secondaryButtonTextRes;
private final MegaphoneText secondaryButtonText;
private final EventListener secondaryButtonListener;
private final EventListener onVisibleListener;
@@ -36,15 +36,15 @@ public class Megaphone {
this.event = builder.event;
this.style = builder.style;
this.canSnooze = builder.canSnooze;
this.titleRes = builder.titleRes;
this.bodyRes = builder.bodyRes;
this.titleText = builder.titleText;
this.bodyText = builder.bodyText;
this.imageRes = builder.imageRes;
this.lottieRes = builder.lottieRes;
this.imageRequest = builder.imageRequest;
this.buttonTextRes = builder.buttonTextRes;
this.buttonText = builder.buttonText;
this.buttonListener = builder.buttonListener;
this.snoozeListener = builder.snoozeListener;
this.secondaryButtonTextRes = builder.secondaryButtonTextRes;
this.secondaryButtonText = builder.secondaryButtonText;
this.secondaryButtonListener = builder.secondaryButtonListener;
this.onVisibleListener = builder.onVisibleListener;
}
@@ -61,12 +61,12 @@ public class Megaphone {
return style;
}
public @StringRes int getTitle() {
return titleRes;
public @NonNull MegaphoneText getTitle() {
return titleText;
}
public @StringRes int getBody() {
return bodyRes;
public @NonNull MegaphoneText getBody() {
return bodyText;
}
public @RawRes int getLottieRes() {
@@ -81,12 +81,12 @@ public class Megaphone {
return imageRequest;
}
public @StringRes int getButtonText() {
return buttonTextRes;
public @Nullable MegaphoneText getButtonText() {
return buttonText;
}
public boolean hasButton() {
return buttonTextRes != 0;
return buttonText != null && buttonText.hasText();
}
public @Nullable EventListener getButtonClickListener() {
@@ -97,12 +97,12 @@ public class Megaphone {
return snoozeListener;
}
public @StringRes int getSecondaryButtonText() {
return secondaryButtonTextRes;
public @Nullable MegaphoneText getSecondaryButtonText() {
return secondaryButtonText;
}
public boolean hasSecondaryButton() {
return secondaryButtonTextRes != 0;
return secondaryButtonText != null && secondaryButtonText.hasText();
}
public @Nullable EventListener getSecondaryButtonClickListener() {
@@ -119,19 +119,18 @@ public class Megaphone {
private final Style style;
private boolean canSnooze;
private int titleRes;
private int bodyRes;
private MegaphoneText titleText;
private MegaphoneText bodyText;
private int imageRes;
private int lottieRes;
private GlideRequest<Drawable> imageRequest;
private int buttonTextRes;
private MegaphoneText buttonText;
private EventListener buttonListener;
private EventListener snoozeListener;
private int secondaryButtonTextRes;
private MegaphoneText secondaryButtonText;
private EventListener secondaryButtonListener;
private EventListener onVisibleListener;
public Builder(@NonNull Event event, @NonNull Style style) {
this.event = event;
this.style = style;
@@ -144,18 +143,28 @@ public class Megaphone {
}
public @NonNull Builder disableSnooze() {
this.canSnooze = false;
this.canSnooze = false;
this.snoozeListener = null;
return this;
}
public @NonNull Builder setTitle(@StringRes int titleRes) {
this.titleRes = titleRes;
this.titleText = MegaphoneText.from(titleRes);
return this;
}
public @NonNull Builder setTitle(@Nullable String title) {
this.titleText = MegaphoneText.from(title);
return this;
}
public @NonNull Builder setBody(@StringRes int bodyRes) {
this.bodyRes = bodyRes;
this.bodyText = MegaphoneText.from(bodyRes);
return this;
}
public @NonNull Builder setBody(String body) {
this.bodyText = MegaphoneText.from(body);
return this;
}
@@ -175,13 +184,25 @@ public class Megaphone {
}
public @NonNull Builder setActionButton(@StringRes int buttonTextRes, @NonNull EventListener listener) {
this.buttonTextRes = buttonTextRes;
this.buttonText = MegaphoneText.from(buttonTextRes);
this.buttonListener = listener;
return this;
}
public @NonNull Builder setActionButton(@NonNull String buttonText, @NonNull EventListener listener) {
this.buttonText = MegaphoneText.from(buttonText);
this.buttonListener = listener;
return this;
}
public @NonNull Builder setSecondaryButton(@StringRes int secondaryButtonTextRes, @NonNull EventListener listener) {
this.secondaryButtonTextRes = secondaryButtonTextRes;
this.secondaryButtonText = MegaphoneText.from(secondaryButtonTextRes);
this.secondaryButtonListener = listener;
return this;
}
public @NonNull Builder setSecondaryButton(@NonNull String secondaryButtonText, @NonNull EventListener listener) {
this.secondaryButtonText = MegaphoneText.from(secondaryButtonText);
this.secondaryButtonListener = listener;
return this;
}
@@ -197,10 +218,14 @@ public class Megaphone {
}
enum Style {
/** Specialized style for onboarding. */
/**
* Specialized style for onboarding.
*/
ONBOARDING,
/** Basic bottom of the screen megaphone with optional snooze and action buttons. */
/**
* Basic bottom of the screen megaphone with optional snooze and action buttons.
*/
BASIC,
/**

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.megaphone
import android.content.Context
import androidx.annotation.StringRes
/**
* Allows setting of megaphone text by string resource or string literal.
*/
data class MegaphoneText(@StringRes private val stringRes: Int = 0, private val string: String? = null) {
@get:JvmName("hasText") val hasText = stringRes != 0 || string != null
fun resolve(context: Context): String? {
return if (stringRes != 0) context.getString(stringRes) else string
}
companion object {
@JvmStatic
fun from(@StringRes stringRes: Int): MegaphoneText = MegaphoneText(stringRes)
@JvmStatic
fun from(string: String): MegaphoneText = MegaphoneText(string = string)
}
}

View File

@@ -11,12 +11,14 @@ import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.signal.core.util.SetUtil;
import org.signal.core.util.TranslationDetection;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -24,6 +26,7 @@ import org.thoughtcrime.securesms.lock.SignalPinReminderDialog;
import org.thoughtcrime.securesms.lock.SignalPinReminders;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.lock.v2.KbsMigrationActivity;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity;
@@ -31,7 +34,6 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LocaleFeatureFlags;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.signal.core.util.SetUtil;
import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity;
@@ -106,6 +108,7 @@ public final class Megaphones {
put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER);
put(Event.TURN_OFF_CENSORSHIP_CIRCUMVENTION, shouldShowTurnOffCircumventionMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : NEVER);
put(Event.DONATE_Q2_2022, shouldShowDonateMegaphone(context, Event.DONATE_Q2_2022, records) ? ShowForDurationSchedule.showForDays(7) : NEVER);
put(Event.REMOTE_MEGAPHONE, shouldShowRemoteMegaphone(records) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)) : NEVER);
put(Event.PIN_REMINDER, new SignalPinReminderSchedule());
// Feature-introduction megaphones should *probably* be added below this divider
@@ -133,6 +136,8 @@ public final class Megaphones {
return buildDonateQ2Megaphone(context);
case TURN_OFF_CENSORSHIP_CIRCUMVENTION:
return buildTurnOffCircumventionMegaphone(context);
case REMOTE_MEGAPHONE:
return buildRemoteMegaphone(context);
default:
throw new IllegalArgumentException("Event not handled!");
}
@@ -292,6 +297,44 @@ public final class Megaphones {
.build();
}
private static @NonNull Megaphone buildRemoteMegaphone(@NonNull Context context) {
RemoteMegaphoneRecord record = RemoteMegaphoneRepository.getRemoteMegaphoneToShow();
if (record != null) {
Megaphone.Builder builder = new Megaphone.Builder(Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC)
.setTitle(record.getTitle())
.setBody(record.getBody());
if (record.getImageUri() != null) {
builder.setImageRequest(GlideApp.with(context).asDrawable().load(record.getImageUri()));
}
if (record.hasPrimaryAction()) {
//noinspection ConstantConditions
builder.setActionButton(record.getPrimaryActionText(), (megaphone, controller) -> {
RemoteMegaphoneRepository.getAction(Objects.requireNonNull(record.getPrimaryActionId()))
.run(context, controller, record);
});
}
if (record.hasSecondaryAction()) {
//noinspection ConstantConditions
builder.setSecondaryButton(record.getSecondaryActionText(), (megaphone, controller) -> {
RemoteMegaphoneRepository.getAction(Objects.requireNonNull(record.getSecondaryActionId()))
.run(context, controller, record);
});
}
builder.setOnVisibleListener((megaphone, controller) -> {
RemoteMegaphoneRepository.markShown(record.getUuid());
});
return builder.build();
} else {
throw new IllegalStateException("No record to show");
}
}
private static boolean shouldShowDonateMegaphone(@NonNull Context context, @NonNull Event event, @NonNull Map<Event, MegaphoneRecord> records) {
long timeSinceLastDonatePrompt = timeSinceLastDonatePrompt(event, records);
@@ -349,6 +392,12 @@ public final class Megaphones {
return true;
}
@WorkerThread
private static boolean shouldShowRemoteMegaphone(@NonNull Map<Event, MegaphoneRecord> records) {
boolean canShowLocalDonate = timeSinceLastDonatePrompt(Event.REMOTE_MEGAPHONE, records) > MIN_TIME_BETWEEN_DONATE_MEGAPHONES;
return RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate);
}
/**
* Unfortunately lastSeen is only set today upon snoozing, which never happens to donate prompts.
* So we use firstVisible as a proxy.
@@ -376,7 +425,8 @@ public final class Megaphones {
ADD_A_PROFILE_PHOTO("add_a_profile_photo"),
BECOME_A_SUSTAINER("become_a_sustainer"),
DONATE_Q2_2022("donate_q2_2022"),
TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention");
TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention"),
REMOTE_MEGAPHONE("remote_megaphone");
private final String key;

View File

@@ -66,16 +66,16 @@ public class PopupMegaphoneView extends FrameLayout {
image.setVisibility(GONE);
}
if (megaphone.getTitle() != 0) {
if (megaphone.getTitle().hasText()) {
titleText.setVisibility(VISIBLE);
titleText.setText(megaphone.getTitle());
titleText.setText(megaphone.getTitle().resolve(getContext()));
} else {
titleText.setVisibility(GONE);
}
if (megaphone.getBody() != 0) {
if (megaphone.getBody().hasText()) {
bodyText.setVisibility(VISIBLE);
bodyText.setText(megaphone.getBody());
bodyText.setText(megaphone.getBody().resolve(getContext()));
} else {
bodyText.setVisibility(GONE);
}

View File

@@ -0,0 +1,111 @@
package org.thoughtcrime.securesms.megaphone
import android.app.Application
import android.content.Context
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord.ActionId
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.megaphone.RemoteMegaphoneRepository.Action
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.LocaleFeatureFlags
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.VersionTracker
import java.util.Objects
/**
* Access point for interacting with Remote Megaphones.
*/
object RemoteMegaphoneRepository {
private val db: RemoteMegaphoneDatabase = SignalDatabase.remoteMegaphones
private val context: Application = ApplicationDependencies.getApplication()
private val snooze: Action = Action { _, controller, _ -> controller.onMegaphoneSnooze(Megaphones.Event.REMOTE_MEGAPHONE) }
private val finish: Action = Action { context, controller, remote ->
if (remote.imageUri != null) {
BlobProvider.getInstance().delete(context, remote.imageUri)
}
controller.onMegaphoneSnooze(Megaphones.Event.REMOTE_MEGAPHONE)
db.markFinished(remote.uuid)
}
private val donate: Action = Action { context, controller, remote ->
controller.onMegaphoneNavigationRequested(AppSettingsActivity.subscriptions(context))
finish.run(context, controller, remote)
}
private val actions = mapOf(
ActionId.SNOOZE.id to snooze,
ActionId.FINISH.id to finish,
ActionId.DONATE.id to donate
)
@WorkerThread
@JvmStatic
fun hasRemoteMegaphoneToShow(canShowLocalDonate: Boolean): Boolean {
val record = getRemoteMegaphoneToShow()
return if (record == null) {
false
} else if (record.primaryActionId?.isDonateAction == true) {
canShowLocalDonate
} else {
true
}
}
@WorkerThread
@JvmStatic
fun getRemoteMegaphoneToShow(): RemoteMegaphoneRecord? {
return db.getPotentialMegaphonesAndClearOld()
.asSequence()
.filter { it.imageUrl == null || it.imageUri != null }
.filter { it.countries == null || LocaleFeatureFlags.shouldShowReleaseNote(it.uuid, it.countries) }
.filter { it.conditionalId == null || checkCondition(it.conditionalId) }
.firstOrNull()
}
@AnyThread
@JvmStatic
fun getAction(action: ActionId): Action {
return actions[action.id] ?: finish
}
@AnyThread
@JvmStatic
fun markShown(uuid: String) {
SignalExecutors.BOUNDED_IO.execute {
db.markShown(uuid)
}
}
private fun checkCondition(conditionalId: String): Boolean {
return when (conditionalId) {
"standard_donate" -> shouldShowDonateMegaphone()
else -> false
}
}
private fun shouldShowDonateMegaphone(): Boolean {
return VersionTracker.getDaysSinceFirstInstalled(context) >= 7 &&
PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.SUCCESS &&
Recipient.self()
.badges
.stream()
.filter { obj: Badge? -> Objects.nonNull(obj) }
.noneMatch { (_, category): Badge -> category === Badge.Category.Donor }
}
fun interface Action {
fun run(context: Context, controller: MegaphoneActionController, remoteMegaphone: RemoteMegaphoneRecord)
}
}