From 22f9bfeceb5f665980c3d4e5635abcf87b44a6a7 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 22 Jan 2020 09:22:19 -0500 Subject: [PATCH] Add support for creating Megaphones. Includes reactions megaphone. --- .../securesms/ApplicationContext.java | 4 +- .../securesms/BasicIntroFragment.java | 4 +- .../ConversationListFragment.java | 110 +++++++++--- .../ConversationListViewModel.java | 36 +++- .../securesms/database/DatabaseFactory.java | 6 + .../securesms/database/MegaphoneDatabase.java | 97 ++++++++++ .../database/helpers/SQLCipherOpenHelper.java | 13 +- .../database/model/MegaphoneRecord.java | 36 ++++ .../dependencies/ApplicationDependencies.java | 13 ++ .../ApplicationDependencyProvider.java | 6 + .../megaphone/BasicMegaphoneView.java | 99 +++++++++++ .../securesms/megaphone/ForeverSchedule.java | 15 ++ .../securesms/megaphone/Megaphone.java | 167 ++++++++++++++++++ .../megaphone/MegaphoneListener.java | 28 +++ .../megaphone/MegaphoneRepository.java | 126 +++++++++++++ .../megaphone/MegaphoneSchedule.java | 5 + .../megaphone/MegaphoneViewBuilder.java | 46 +++++ .../securesms/megaphone/Megaphones.java | 117 ++++++++++++ .../megaphone/RecurringSchedule.java | 21 +++ .../reactions/ReactionsMegaphoneView.java | 37 ++++ .../megaphone_background_shadow.9.png | Bin 0 -> 11412 bytes .../reactions_megaphone_background.xml | 10 ++ .../main/res/layout/basic_megaphone_view.xml | 93 ++++++++++ .../res/layout/conversation_list_fragment.xml | 37 +++- .../main/res/layout/reactions_megaphone.xml | 88 +++++++++ .../res/raw/lottie_reactions_megaphone.json | 1 + app/src/main/res/values/attrs.xml | 6 + app/src/main/res/values/strings.xml | 7 +- app/src/main/res/values/themes.xml | 12 ++ 29 files changed, 1195 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/MegaphoneDatabase.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/MegaphoneRecord.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/ForeverSchedule.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneListener.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneSchedule.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/RecurringSchedule.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsMegaphoneView.java create mode 100644 app/src/main/res/drawable/megaphone_background_shadow.9.png create mode 100644 app/src/main/res/drawable/reactions_megaphone_background.xml create mode 100644 app/src/main/res/layout/basic_megaphone_view.xml create mode 100644 app/src/main/res/layout/reactions_megaphone.xml create mode 100644 app/src/main/res/raw/lottie_reactions_megaphone.json diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 0d07d12559..c554cfa1ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -49,7 +49,7 @@ import org.thoughtcrime.securesms.jobs.FcmRefreshJob; import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; -import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; import org.thoughtcrime.securesms.logging.AndroidLogger; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; import org.thoughtcrime.securesms.logging.Log; @@ -155,6 +155,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi executePendingContactSync(); KeyCachingService.onAppForegrounded(this); ApplicationDependencies.getFrameRateTracker().begin(); + ApplicationDependencies.getMegaphoneRepository().onAppForegrounded(); } @Override @@ -248,6 +249,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi TextSecurePreferences.setJobManagerVersion(this, JobManager.CURRENT_VERSION); TextSecurePreferences.setLastExperienceVersionCode(this, Util.getCanonicalVersionCode()); TextSecurePreferences.setHasSeenStickerIntroTooltip(this, true); + ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch(); ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false)); ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BasicIntroFragment.java b/app/src/main/java/org/thoughtcrime/securesms/BasicIntroFragment.java index 0b74b992ee..d73a678023 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BasicIntroFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BasicIntroFragment.java @@ -36,8 +36,8 @@ public class BasicIntroFragment extends Fragment { super.onCreate(savedInstanceState); if (getArguments() != null) { drawable = getArguments().getInt(ARG_DRAWABLE); - text = getArguments().getInt(ARG_TEXT ); - subtext = getArguments().getInt(ARG_SUBTEXT ); + text = getArguments().getInt(ARG_TEXT); + subtext = getArguments().getInt(ARG_SUBTEXT); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 3ef66d23ca..f7312752f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -29,7 +29,6 @@ import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; -import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; @@ -41,13 +40,16 @@ import androidx.annotation.MenuRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.google.android.material.snackbar.Snackbar; import androidx.annotation.PluralsRes; +import androidx.annotation.StringRes; import androidx.annotation.WorkerThread; import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.TooltipCompat; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; import androidx.lifecycle.ViewModelProviders; import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; @@ -57,7 +59,7 @@ import androidx.appcompat.view.ActionMode; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.ItemTouchHelper; -import android.text.TextUtils; + import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -77,7 +79,6 @@ import org.thoughtcrime.securesms.MainNavigator; import org.thoughtcrime.securesms.NewConversationActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.conversationlist.ConversationListAdapter.ItemClickListener; -import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.components.RatingManager; import org.thoughtcrime.securesms.components.SearchToolbar; import org.thoughtcrime.securesms.components.recyclerview.DeleteItemAnimator; @@ -93,9 +94,6 @@ import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder; import org.thoughtcrime.securesms.components.reminder.ShareReminder; import org.thoughtcrime.securesms.components.reminder.SystemSmsImportReminder; import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; -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.conversationlist.model.MessageResult; import org.thoughtcrime.securesms.conversationlist.model.SearchResult; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -110,6 +108,10 @@ import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob; import org.thoughtcrime.securesms.lock.RegistrationLockDialog; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediasend.MediaSendActivity; +import org.thoughtcrime.securesms.megaphone.Megaphone; +import org.thoughtcrime.securesms.megaphone.MegaphoneListener; +import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder; +import org.thoughtcrime.securesms.megaphone.Megaphones; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MessageNotifier; @@ -137,7 +139,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana ActionMode.Callback, ItemClickListener, ConversationListSearchAdapter.EventListener, - MainNavigator.BackHandler + MainNavigator.BackHandler, + MegaphoneListener { private static final String TAG = Log.tag(ConversationListFragment.class); @@ -163,6 +166,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana private ConversationListAdapter defaultAdapter; private ConversationListSearchAdapter searchAdapter; private StickyHeaderDecoration searchAdapterDecoration; + private ViewGroup megaphoneContainer; public static ConversationListFragment newInstance() { return new ConversationListFragment(); @@ -181,16 +185,17 @@ public class ConversationListFragment extends MainFragment implements LoaderMana @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - reminderView = view.findViewById(R.id.reminder); - list = view.findViewById(R.id.list); - fab = view.findViewById(R.id.fab); - cameraFab = view.findViewById(R.id.camera_fab); - emptyState = view.findViewById(R.id.empty_state); - emptyImage = view.findViewById(R.id.empty); - searchEmptyState = view.findViewById(R.id.search_no_results); - searchToolbar = view.findViewById(R.id.search_toolbar); - searchAction = view.findViewById(R.id.search_action); - toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow); + reminderView = view.findViewById(R.id.reminder); + list = view.findViewById(R.id.list); + fab = view.findViewById(R.id.fab); + cameraFab = view.findViewById(R.id.camera_fab); + emptyState = view.findViewById(R.id.empty_state); + emptyImage = view.findViewById(R.id.empty); + searchEmptyState = view.findViewById(R.id.search_no_results); + searchToolbar = view.findViewById(R.id.search_toolbar); + searchAction = view.findViewById(R.id.search_action); + toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow); + megaphoneContainer = view.findViewById(R.id.megaphone_container); Toolbar toolbar = view.findViewById(getToolbarRes()); toolbar.setVisibility(View.VISIBLE); @@ -339,6 +344,26 @@ public class ConversationListFragment extends MainFragment implements LoaderMana }); } + @Override + public void onMegaphoneNavigationRequested(@NonNull Intent intent) { + startActivity(intent); + } + + @Override + public void onMegaphoneToastRequested(int stringRes) { + Toast.makeText(requireContext(), stringRes, Toast.LENGTH_SHORT).show(); + } + + @Override + public void onMegaphoneSnooze(@NonNull Megaphone megaphone) { + viewModel.onMegaphoneSnoozed(megaphone); + } + + @Override + public void onMegaphoneCompleted(@NonNull Megaphone megaphone) { + viewModel.onMegaphoneCompleted(megaphone.getEvent()); + } + private void initializeProfileIcon(@NonNull Recipient recipient) { ImageView icon = requireView().findViewById(R.id.toolbar_icon); @@ -406,19 +431,52 @@ public class ConversationListFragment extends MainFragment implements LoaderMana private void initializeViewModel() { viewModel = ViewModelProviders.of(this, new ConversationListViewModel.Factory()).get(ConversationListViewModel.class); - viewModel.getSearchResult().observe(this, result -> { - result = result != null ? result : SearchResult.EMPTY; - searchAdapter.updateResults(result); + viewModel.getSearchResult().observe(this, this::onSearchResultChanged); + viewModel.getMegaphone().observe(this, this::onMegaphoneChanged); - if (result.isEmpty() && activeAdapter == searchAdapter) { - searchEmptyState.setText(getString(R.string.SearchFragment_no_results, result.getQuery())); - searchEmptyState.setVisibility(View.VISIBLE); - } else { - searchEmptyState.setVisibility(View.GONE); + ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() { + @Override + public void onStart(@NonNull LifecycleOwner owner) { + viewModel.onVisible(); } }); } + private void onSearchResultChanged(@Nullable SearchResult result) { + result = result != null ? result : SearchResult.EMPTY; + searchAdapter.updateResults(result); + + if (result.isEmpty() && activeAdapter == searchAdapter) { + searchEmptyState.setText(getString(R.string.SearchFragment_no_results, result.getQuery())); + searchEmptyState.setVisibility(View.VISIBLE); + } else { + searchEmptyState.setVisibility(View.GONE); + } + } + + private void onMegaphoneChanged(@Nullable Megaphone megaphone) { + if (megaphone == null) { + megaphoneContainer.setVisibility(View.GONE); + megaphoneContainer.removeAllViews(); + return; + } + + View view = MegaphoneViewBuilder.build(requireContext(), megaphone, this); + + megaphoneContainer.removeAllViews(); + + if (view != null) { + megaphoneContainer.addView(view); + megaphoneContainer.setVisibility(View.VISIBLE); + } else { + megaphoneContainer.setVisibility(View.GONE); + + if (megaphone.getOnVisibleListener() != null) { + megaphone.getOnVisibleListener().onVisible(megaphone, this); + } + } + } + private void updateReminders() { Context context = requireContext(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java index d71229efae..6f32230627 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java @@ -14,6 +14,10 @@ import android.text.TextUtils; import org.thoughtcrime.securesms.conversationlist.model.SearchResult; import org.thoughtcrime.securesms.database.DatabaseContentProviders; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.megaphone.Megaphone; +import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; +import org.thoughtcrime.securesms.megaphone.Megaphones; import org.thoughtcrime.securesms.search.SearchRepository; import org.thoughtcrime.securesms.util.Debouncer; import org.thoughtcrime.securesms.util.Util; @@ -21,19 +25,23 @@ import org.thoughtcrime.securesms.util.Util; class ConversationListViewModel extends ViewModel { private final Application application; + private final MutableLiveData megaphone; private final MutableLiveData searchResult; private final SearchRepository searchRepository; + private final MegaphoneRepository megaphoneRepository; private final Debouncer debouncer; private final ContentObserver observer; private String lastQuery; private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository) { - this.application = application; - this.searchResult = new MutableLiveData<>(); - this.searchRepository = searchRepository; - this.debouncer = new Debouncer(300); - this.observer = new ContentObserver(new Handler()) { + this.application = application; + this.megaphone = new MutableLiveData<>(); + this.searchResult = new MutableLiveData<>(); + this.searchRepository = searchRepository; + this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository(); + this.debouncer = new Debouncer(300); + this.observer = new ContentObserver(new Handler()) { @Override public void onChange(boolean selfChange) { if (!TextUtils.isEmpty(getLastQuery())) { @@ -49,6 +57,24 @@ class ConversationListViewModel extends ViewModel { return searchResult; } + @NonNull LiveData getMegaphone() { + return megaphone; + } + + void onVisible() { + megaphoneRepository.getNextMegaphone(megaphone::postValue); + } + + void onMegaphoneCompleted(@NonNull Megaphones.Event event) { + megaphone.postValue(null); + megaphoneRepository.markFinished(event); + } + + void onMegaphoneSnoozed(@NonNull Megaphone snoozed) { + megaphoneRepository.markSeen(snoozed); + megaphone.postValue(null); + } + void updateQuery(String query) { lastQuery = query; debouncer.publish(() -> searchRepository.query(query, result -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index 307157a3c2..571b28e6ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -61,6 +61,7 @@ public class DatabaseFactory { private final StickerDatabase stickerDatabase; private final StorageKeyDatabase storageKeyDatabase; private final KeyValueDatabase keyValueDatabase; + private final MegaphoneDatabase megaphoneDatabase; public static DatabaseFactory getInstance(Context context) { synchronized (lock) { @@ -155,6 +156,10 @@ public class DatabaseFactory { return getInstance(context).keyValueDatabase; } + public static MegaphoneDatabase getMegaphoneDatabase(Context context) { + return getInstance(context).megaphoneDatabase; + } + public static SQLiteDatabase getBackupDatabase(Context context) { return getInstance(context).databaseHelper.getReadableDatabase(); } @@ -193,6 +198,7 @@ public class DatabaseFactory { this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret); this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper); this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper); + this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper); } public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MegaphoneDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MegaphoneDatabase.java new file mode 100644 index 0000000000..24c30c4dce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MegaphoneDatabase.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MegaphoneRecord; +import org.thoughtcrime.securesms.megaphone.Megaphones.Event; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * IMPORTANT: Writes should only be made through {@link org.thoughtcrime.securesms.megaphone.MegaphoneRepository}. + */ +public class MegaphoneDatabase extends Database { + + private static final String TABLE_NAME = "megaphone"; + + private static final String ID = "_id"; + private static final String EVENT = "event"; + private static final String SEEN_COUNT = "seen_count"; + private static final String LAST_SEEN = "last_seen"; + private static final String FINISHED = "finished"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + EVENT + " TEXT UNIQUE, " + + SEEN_COUNT + " INTEGER, " + + LAST_SEEN + " INTEGER, " + + FINISHED + " INTEGER)"; + + MegaphoneDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public void insert(@NonNull Collection events) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (Event event : events) { + ContentValues values = new ContentValues(); + values.put(EVENT, event.getKey()); + + db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public @NonNull List getAll() { + List records = new ArrayList<>(); + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String event = cursor.getString(cursor.getColumnIndexOrThrow(EVENT)); + int seenCount = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_COUNT)); + long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(LAST_SEEN)); + boolean finished = cursor.getInt(cursor.getColumnIndexOrThrow(FINISHED)) == 1; + + records.add(new MegaphoneRecord(Event.fromKey(event), seenCount, lastSeen, finished)); + } + } + + return records; + } + + public void markSeen(@NonNull Event event, int seenCount, long lastSeen) { + String query = EVENT + " = ?"; + String[] args = new String[]{event.getKey()}; + + ContentValues values = new ContentValues(); + values.put(SEEN_COUNT, seenCount); + values.put(LAST_SEEN, lastSeen); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, values, query, args); + } + + public void markFinished(@NonNull Event event) { + String query = EVENT + " = ?"; + String[] args = new String[]{event.getKey()}; + + ContentValues values = new ContentValues(); + values.put(FINISHED, 1); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, values, query, args); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index ee41523f5c..e469550628 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.JobDatabase; import org.thoughtcrime.securesms.database.KeyValueDatabase; +import org.thoughtcrime.securesms.database.MegaphoneDatabase; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase; import org.thoughtcrime.securesms.database.PushDatabase; @@ -102,8 +103,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int ATTACHMENT_DISPLAY_ORDER = 42; private static final int SPLIT_PROFILE_NAMES = 43; private static final int STICKER_PACK_ORDER = 44; + private static final int MEGAPHONES = 45; - private static final int DATABASE_VERSION = 44; + private static final int DATABASE_VERSION = 45; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -146,6 +148,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(StickerDatabase.CREATE_TABLE); db.execSQL(StorageKeyDatabase.CREATE_TABLE); db.execSQL(KeyValueDatabase.CREATE_TABLE); + db.execSQL(MegaphoneDatabase.CREATE_TABLE); executeStatements(db, SearchDatabase.CREATE_TABLE); executeStatements(db, JobDatabase.CREATE_TABLE); @@ -708,6 +711,14 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE sticker ADD COLUMN pack_order INTEGER DEFAULT 0"); } + if (oldVersion < MEGAPHONES) { + db.execSQL("CREATE TABLE megaphone (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "event TEXT UNIQUE, " + + "seen_count INTEGER, " + + "last_seen INTEGER, " + + "finished INTEGER)"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MegaphoneRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MegaphoneRecord.java new file mode 100644 index 0000000000..28f476001c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MegaphoneRecord.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.megaphone.Megaphones; + +public class MegaphoneRecord { + + private final Megaphones.Event event; + private final int seenCount; + private final long lastSeen; + private final boolean finished; + + public MegaphoneRecord(@NonNull Megaphones.Event event, int seenCount, long lastSeen, boolean finished) { + this.event = event; + this.seenCount = seenCount; + this.lastSeen = lastSeen; + this.finished = finished; + } + + public @NonNull Megaphones.Event getEvent() { + return event; + } + + public int getSeenCount() { + return seenCount; + } + + public long getLastSeen() { + return lastSeen; + } + + public boolean isFinished() { + return finished; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index d61d3f2e6d..323330cf55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.IncomingMessageProcessor; import org.thoughtcrime.securesms.gcm.MessageRetriever; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.keyvalue.KeyValueStore; +import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.recipients.LiveRecipientCache; import org.thoughtcrime.securesms.service.IncomingMessageObserver; @@ -42,6 +43,7 @@ public class ApplicationDependencies { private static JobManager jobManager; private static FrameRateTracker frameRateTracker; private static KeyValueStore keyValueStore; + private static MegaphoneRepository megaphoneRepository; public static synchronized void init(@NonNull Application application, @NonNull Provider provider) { if (ApplicationDependencies.application != null || ApplicationDependencies.provider != null) { @@ -167,6 +169,16 @@ public class ApplicationDependencies { return keyValueStore; } + public static synchronized @NonNull MegaphoneRepository getMegaphoneRepository() { + assertInitialization(); + + if (megaphoneRepository == null) { + megaphoneRepository = provider.provideMegaphoneRepository(); + } + + return megaphoneRepository; + } + private static void assertInitialization() { if (application == null || provider == null) { throw new UninitializedException(); @@ -184,6 +196,7 @@ public class ApplicationDependencies { @NonNull JobManager provideJobManager(); @NonNull FrameRateTracker provideFrameRateTracker(); @NonNull KeyValueStore provideKeyValueStore(); + @NonNull MegaphoneRepository provideMegaphoneRepository(); } private static class UninitializedException extends IllegalStateException { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 6926c13763..6503bc76ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.jobs.FastJobStorage; import org.thoughtcrime.securesms.jobs.JobManagerFactories; import org.thoughtcrime.securesms.keyvalue.KeyValueStore; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; import org.thoughtcrime.securesms.push.SecurityEventListener; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.recipients.LiveRecipientCache; @@ -125,6 +126,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr return new KeyValueStore(context); } + @Override + public @NonNull MegaphoneRepository provideMegaphoneRepository() { + return new MegaphoneRepository(context); + } + private static class DynamicCredentialsProvider implements CredentialsProvider { private final Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java new file mode 100644 index 0000000000..42047eb753 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java @@ -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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/ForeverSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ForeverSchedule.java new file mode 100644 index 0000000000..4a0c624516 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ForeverSchedule.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java new file mode 100644 index 0000000000..a5e137354f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java @@ -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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneListener.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneListener.java new file mode 100644 index 0000000000..21aa8bb8e3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneListener.java @@ -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); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java new file mode 100644 index 0000000000..82b0aa5927 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java @@ -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 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 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 records = database.getAll(); + Set events = Stream.of(records).map(MegaphoneRecord::getEvent).collect(Collectors.toSet()); + Set 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 { + void onResult(E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneSchedule.java new file mode 100644 index 0000000000..2ca93f76a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneSchedule.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.megaphone; + +public interface MegaphoneSchedule { + boolean shouldDisplay(int seenCount, long lastSeen, long currentTime); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java new file mode 100644 index 0000000000..ca37a8ff98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java new file mode 100644 index 0000000000..7c83a9130a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -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 records) { + long currentTime = System.currentTimeMillis(); + + List 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 buildDisplayOrder() { + return new LinkedHashMap() {{ + 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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/RecurringSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/RecurringSchedule.java new file mode 100644 index 0000000000..43862a657d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/RecurringSchedule.java @@ -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 ; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsMegaphoneView.java new file mode 100644 index 0000000000..fe6e98743f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionsMegaphoneView.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.megaphone.Megaphone; +import org.thoughtcrime.securesms.megaphone.MegaphoneListener; + +public class ReactionsMegaphoneView extends FrameLayout { + + private View closeButton; + + public ReactionsMegaphoneView(Context context) { + super(context); + initialize(context); + } + + public ReactionsMegaphoneView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context); + } + + private void initialize(@NonNull Context context) { + inflate(context, R.layout.reactions_megaphone, this); + + this.closeButton = findViewById(R.id.reactions_megaphone_x); + } + + public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneListener listener) { + this.closeButton.setOnClickListener(v -> listener.onMegaphoneCompleted(megaphone)); + } +} diff --git a/app/src/main/res/drawable/megaphone_background_shadow.9.png b/app/src/main/res/drawable/megaphone_background_shadow.9.png new file mode 100644 index 0000000000000000000000000000000000000000..8a9e4b4c10ca5eaf17c953d29a2d6f703d23a0ce GIT binary patch literal 11412 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 iF_2~1POg2q$M*kio?GLfJAkkN0000 + + diff --git a/app/src/main/res/layout/basic_megaphone_view.xml b/app/src/main/res/layout/basic_megaphone_view.xml new file mode 100644 index 0000000000..2cded62036 --- /dev/null +++ b/app/src/main/res/layout/basic_megaphone_view.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + +