mirror of
https://github.com/oxen-io/session-android.git
synced 2025-12-31 04:26:08 +00:00
Add support for creating Megaphones. Includes reactions megaphone.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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> megaphone;
|
||||
private final MutableLiveData<SearchResult> 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<Megaphone> 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 -> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Event> 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<MegaphoneRecord> getAll() {
|
||||
List<MegaphoneRecord> 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.thoughtcrime.securesms.megaphone;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MegaphoneDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones.Event;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* Synchronization of data structures is done using a serial executor. Do not access or change
|
||||
* data structures or fields on anything except the executor.
|
||||
*/
|
||||
public class MegaphoneRepository {
|
||||
|
||||
private final Context context;
|
||||
private final Executor executor;
|
||||
private final MegaphoneDatabase database;
|
||||
private final Map<Event, MegaphoneRecord> databaseCache;
|
||||
|
||||
private boolean enabled;
|
||||
|
||||
public MegaphoneRepository(@NonNull Context context) {
|
||||
this.context = context;
|
||||
this.executor = SignalExecutors.SERIAL;
|
||||
this.database = DatabaseFactory.getMegaphoneDatabase(context);
|
||||
this.databaseCache = new HashMap<>();
|
||||
|
||||
executor.execute(this::init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks any megaphones a new user shouldn't see as "finished".
|
||||
*/
|
||||
@MainThread
|
||||
public void onFirstEverAppLaunch() {
|
||||
executor.execute(() -> {
|
||||
// Future megaphones we don't want to show to new users should get marked as finished here.
|
||||
});
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void onAppForegrounded() {
|
||||
executor.execute(() -> enabled = true);
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void getNextMegaphone(@NonNull Callback<Megaphone> callback) {
|
||||
executor.execute(() -> {
|
||||
if (enabled) {
|
||||
callback.onResult(Megaphones.getNextMegaphone(context, databaseCache));
|
||||
} else {
|
||||
callback.onResult(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void markSeen(@NonNull Megaphone megaphone) {
|
||||
long lastSeen = System.currentTimeMillis();
|
||||
|
||||
executor.execute(() -> {
|
||||
Event event = megaphone.getEvent();
|
||||
MegaphoneRecord record = getRecord(event);
|
||||
|
||||
if (megaphone.getMaxAppearances() != Megaphone.UNLIMITED &&
|
||||
record.getSeenCount() + 1 >= megaphone.getMaxAppearances())
|
||||
{
|
||||
database.markFinished(event);
|
||||
} else {
|
||||
database.markSeen(event, record.getSeenCount() + 1, lastSeen);
|
||||
}
|
||||
|
||||
enabled = false;
|
||||
resetDatabaseCache();
|
||||
});
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void markFinished(@NonNull Event event) {
|
||||
executor.execute(() -> {
|
||||
database.markFinished(event);
|
||||
resetDatabaseCache();
|
||||
});
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void init() {
|
||||
List<MegaphoneRecord> records = database.getAll();
|
||||
Set<Event> events = Stream.of(records).map(MegaphoneRecord::getEvent).collect(Collectors.toSet());
|
||||
Set<Event> missing = Stream.of(Megaphones.Event.values()).filterNot(events::contains).collect(Collectors.toSet());
|
||||
|
||||
database.insert(missing);
|
||||
resetDatabaseCache();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull MegaphoneRecord getRecord(@NonNull Event event) {
|
||||
//noinspection ConstantConditions
|
||||
return databaseCache.get(event);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void resetDatabaseCache() {
|
||||
databaseCache.clear();
|
||||
databaseCache.putAll(Stream.of(database.getAll()).collect(Collectors.toMap(MegaphoneRecord::getEvent, m -> m)));
|
||||
}
|
||||
|
||||
public interface Callback<E> {
|
||||
void onResult(E result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.megaphone;
|
||||
|
||||
public interface MegaphoneSchedule {
|
||||
boolean shouldDisplay(int seenCount, long lastSeen, long currentTime);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.thoughtcrime.securesms.megaphone;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Creating a new megaphone:
|
||||
* - Add an enum to {@link Event}
|
||||
* - Return a megaphone in {@link #forRecord(MegaphoneRecord)}
|
||||
* - Include the event in {@link #buildDisplayOrder()}
|
||||
*
|
||||
* Common patterns:
|
||||
* - For events that have a snooze-able recurring display schedule, use a {@link RecurringSchedule}.
|
||||
* - For events guarded by feature flags, set a {@link ForeverSchedule} with false in
|
||||
* {@link #buildDisplayOrder()}.
|
||||
* - For events that change, return different megaphones in {@link #forRecord(MegaphoneRecord)}
|
||||
* based on whatever properties you're interested in.
|
||||
*/
|
||||
public final class Megaphones {
|
||||
|
||||
private Megaphones() {}
|
||||
|
||||
static @Nullable Megaphone getNextMegaphone(@NonNull Context context, @NonNull Map<Event, MegaphoneRecord> records) {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
|
||||
List<Megaphone> megaphones = Stream.of(buildDisplayOrder())
|
||||
.filter(e -> {
|
||||
MegaphoneRecord record = Objects.requireNonNull(records.get(e.getKey()));
|
||||
MegaphoneSchedule schedule = e.getValue();
|
||||
|
||||
return !record.isFinished() && schedule.shouldDisplay(record.getSeenCount(), record.getLastSeen(), currentTime);
|
||||
})
|
||||
.map(Map.Entry::getKey)
|
||||
.map(records::get)
|
||||
.map(Megaphones::forRecord)
|
||||
.toList();
|
||||
|
||||
boolean hasOptional = Stream.of(megaphones).anyMatch(m -> !m.isMandatory());
|
||||
boolean hasMandatory = Stream.of(megaphones).anyMatch(Megaphone::isMandatory);
|
||||
|
||||
if (hasOptional && hasMandatory) {
|
||||
megaphones = Stream.of(megaphones).filter(Megaphone::isMandatory).toList();
|
||||
}
|
||||
|
||||
if (megaphones.size() > 0) {
|
||||
return megaphones.get(0);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is when you would hide certain megaphones based on {@link FeatureFlags}. You could
|
||||
* conditionally set a {@link ForeverSchedule} set to false for disabled features.
|
||||
*/
|
||||
private static Map<Event, MegaphoneSchedule> buildDisplayOrder() {
|
||||
return new LinkedHashMap<Event, MegaphoneSchedule>() {{
|
||||
put(Event.REACTIONS, new ForeverSchedule(FeatureFlags.reactionSending()));
|
||||
}};
|
||||
}
|
||||
|
||||
private static @NonNull Megaphone forRecord(@NonNull MegaphoneRecord record) {
|
||||
switch (record.getEvent()) {
|
||||
case REACTIONS:
|
||||
return buildReactionsMegaphone();
|
||||
default:
|
||||
throw new IllegalArgumentException("Event not handled!");
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull Megaphone buildReactionsMegaphone() {
|
||||
return new Megaphone.Builder(Event.REACTIONS, Megaphone.Style.REACTIONS)
|
||||
.setMaxAppearances(Megaphone.UNLIMITED)
|
||||
.setMandatory(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
public enum Event {
|
||||
REACTIONS("reactions");
|
||||
|
||||
private final String key;
|
||||
|
||||
Event(@NonNull String key) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
public @NonNull String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public static Event fromKey(@NonNull String key) {
|
||||
for (Event event : values()) {
|
||||
if (event.getKey().equals(key)) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("No event for key: " + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user