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 0000000000..8a9e4b4c10 Binary files /dev/null and b/app/src/main/res/drawable/megaphone_background_shadow.9.png differ diff --git a/app/src/main/res/drawable/reactions_megaphone_background.xml b/app/src/main/res/drawable/reactions_megaphone_background.xml new file mode 100644 index 0000000000..8fa1174e69 --- /dev/null +++ b/app/src/main/res/drawable/reactions_megaphone_background.xml @@ -0,0 +1,10 @@ + + + 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 @@ + + + + + + + + + + + + + + + + +