diff --git a/build.gradle b/build.gradle index 9c84e83d7e..9c9138e2ea 100644 --- a/build.gradle +++ b/build.gradle @@ -105,6 +105,7 @@ dependencies { exclude group: 'com.android.support', module: 'appcompat-v7' exclude group: 'com.android.support', module: 'recyclerview-v7' } + compile 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4' testCompile 'junit:junit:4.12' @@ -171,6 +172,7 @@ dependencyVerification { 'com.klinkerapps:android-smsmms:e7c3328a0f3a8dd44daa8129de4e99996f3057a4546e47891b036b81e0ebf1d1', 'com.annimon:stream:5da6e2e3e0551d61a3ea7014f04312276549e3dd739cf637996e4cf43c5535b9', 'com.takisoft.fix:colorpicker:f5d0dbabe406a1800498ca9c1faf34db36e021d8488bf10360f29961fe3ab0d1', + 'com.codewaves.stickyheadergrid:stickyheadergrid:5b4aa6a52a957cfd55f60f4220c11c0c371385a3cb9786cae03c260dcdef5794', 'com.android.support:support-annotations:a774272036941b4e912eb426d70c848bde7f06a3bf5fb491f75a427dc6595270', 'com.android.support:support-v4:ee44c481a1f4d6978568e223e8125379b52b2ececdd53450e09ebae144bd377d', 'com.android.support:support-vector-drawable:077009d13882ee96f061e4bc2dbe7cce7ae1762d8297592a787ff741afbfb1f2', diff --git a/res/layout/media_overview_activity.xml b/res/layout/media_overview_activity.xml index d9e3d7afb4..e1aa997ce8 100644 --- a/res/layout/media_overview_activity.xml +++ b/res/layout/media_overview_activity.xml @@ -4,7 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/gray95"> + android:background="@color/white"> diff --git a/res/layout/media_overview_item.xml b/res/layout/media_overview_item.xml index 715651a9c5..5c649bdcbb 100644 --- a/res/layout/media_overview_item.xml +++ b/res/layout/media_overview_item.xml @@ -2,7 +2,8 @@ + android:layout_height="match_parent" + android:padding="2dp"> + + + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 6a9e364e55..c12f1bb47f 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1469,6 +1469,10 @@ Default alarm sound Add ringtone Unable to add custom ringtone + Today + Yesterday + This week + This month diff --git a/src/org/thoughtcrime/securesms/MediaAdapter.java b/src/org/thoughtcrime/securesms/MediaAdapter.java index 4d2392ff50..04554c99c7 100644 --- a/src/org/thoughtcrime/securesms/MediaAdapter.java +++ b/src/org/thoughtcrime/securesms/MediaAdapter.java @@ -18,65 +18,104 @@ package org.thoughtcrime.securesms; import android.content.Context; import android.content.Intent; -import android.database.Cursor; -import android.support.annotation.NonNull; -import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; -import android.view.View.OnClickListener; import android.view.ViewGroup; +import android.widget.TextView; + +import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; -import org.thoughtcrime.securesms.MediaAdapter.ViewHolder; import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.Address; -import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; +import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.util.MediaUtil; -public class MediaAdapter extends CursorRecyclerViewAdapter { +import java.util.Locale; + +public class MediaAdapter extends StickyHeaderGridAdapter { private static final String TAG = MediaAdapter.class.getSimpleName(); - private final MasterSecret masterSecret; - private final Address address; + private final Context context; + private final MasterSecret masterSecret; + private final Locale locale; + private final Address address; + + private BucketedThreadMedia media; - public static class ViewHolder extends RecyclerView.ViewHolder { - public ThumbnailView imageView; + private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder { + ThumbnailView imageView; - public ViewHolder(View v) { + ViewHolder(View v) { super(v); imageView = (ThumbnailView) v.findViewById(R.id.image); } } - public MediaAdapter(Context context, MasterSecret masterSecret, Cursor c, Address address) { - super(context, c); + private static class HeaderHolder extends StickyHeaderGridAdapter.HeaderViewHolder { + TextView textView; + + HeaderHolder(View itemView) { + super(itemView); + textView = (TextView) itemView.findViewById(R.id.text); + } + } + + public MediaAdapter(Context context, MasterSecret masterSecret, BucketedThreadMedia media, Locale locale, Address address) { + this.context = context; this.masterSecret = masterSecret; + this.locale = locale; + this.media = media; this.address = address; } + public void setMedia(BucketedThreadMedia media) { + this.media = media; + } + @Override - public ViewHolder onCreateItemViewHolder(final ViewGroup viewGroup, final int i) { - final View view = LayoutInflater.from(getContext()).inflate(R.layout.media_overview_item, viewGroup, false); - return new ViewHolder(view); + public StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) { + return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_item_header, parent, false)); } @Override - public void onBindItemViewHolder(final ViewHolder viewHolder, final @NonNull Cursor cursor) { - final ThumbnailView imageView = viewHolder.imageView; - final MediaRecord mediaRecord = MediaRecord.from(getContext(), masterSecret, cursor); + public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) { + return new ViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_item, parent, false)); + } - Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment()); + @Override + public void onBindHeaderViewHolder(StickyHeaderGridAdapter.HeaderViewHolder viewHolder, int section) { + ((HeaderHolder)viewHolder).textView.setText(media.getName(section, locale)); + } + + @Override + public void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset) { + MediaRecord mediaRecord = media.get(section, offset); + ThumbnailView thumbnailView = ((ViewHolder)viewHolder).imageView; + + Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); if (slide != null) { - imageView.setImageResource(masterSecret, slide, false, false); + thumbnailView.setImageResource(masterSecret, slide, false, false); } - imageView.setOnClickListener(new OnMediaClickListener(mediaRecord)); + thumbnailView.setOnClickListener(new OnMediaClickListener(mediaRecord)); + } + + @Override + public int getSectionCount() { + return media.getSectionCount(); } - private class OnMediaClickListener implements OnClickListener { + @Override + public int getSectionItemCount(int section) { + return media.getSectionItemCount(section); + } + + private class OnMediaClickListener implements View.OnClickListener { + private final MediaRecord mediaRecord; private OnMediaClickListener(MediaRecord mediaRecord) { @@ -86,7 +125,7 @@ public class MediaAdapter extends CursorRecyclerViewAdapter { @Override public void onClick(View v) { if (mediaRecord.getAttachment().getDataUri() != null) { - Intent intent = new Intent(getContext(), MediaPreviewActivity.class); + Intent intent = new Intent(context, MediaPreviewActivity.class); intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate()); intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize()); intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, address); @@ -96,8 +135,9 @@ public class MediaAdapter extends CursorRecyclerViewAdapter { } intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType()); - getContext().startActivity(intent); + context.startActivity(intent); } } } + } diff --git a/src/org/thoughtcrime/securesms/MediaOverviewActivity.java b/src/org/thoughtcrime/securesms/MediaOverviewActivity.java index 73ad46f2dc..17363edf5a 100644 --- a/src/org/thoughtcrime/securesms/MediaOverviewActivity.java +++ b/src/org/thoughtcrime/securesms/MediaOverviewActivity.java @@ -16,37 +16,34 @@ */ package org.thoughtcrime.securesms; -import android.annotation.TargetApi; import android.content.Context; import android.content.DialogInterface; import android.content.res.Configuration; import android.database.Cursor; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.v4.app.LoaderManager; import android.support.v4.content.Loader; -import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.RecyclerView; -import android.util.Log; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; -import android.view.WindowManager; import android.widget.TextView; +import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; + import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.Address; -import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; -import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; +import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader; +import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientModifiedListener; import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; import java.util.ArrayList; @@ -55,95 +52,73 @@ import java.util.List; /** * Activity for displaying media attachments in-app */ -public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity implements LoaderManager.LoaderCallbacks { +public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity implements LoaderManager.LoaderCallbacks { private final static String TAG = MediaOverviewActivity.class.getSimpleName(); public static final String ADDRESS_EXTRA = "address"; + private final DynamicTheme dynamicTheme = new DynamicTheme(); private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); private MasterSecret masterSecret; private RecyclerView gridView; - private GridLayoutManager gridManager; + private StickyHeaderGridLayoutManager gridManager; private TextView noImages; private Recipient recipient; @Override protected void onPreCreate() { - this.setTheme(R.style.TextSecure_DarkTheme); + dynamicTheme.onCreate(this); dynamicLanguage.onCreate(this); } @Override protected void onCreate(Bundle bundle, @NonNull MasterSecret masterSecret) { this.masterSecret = masterSecret; - setFullscreenIfPossible(); getSupportActionBar().setDisplayHomeAsUpEnabled(true); setContentView(R.layout.media_overview_activity); initializeResources(); initializeActionBar(); + getSupportLoaderManager().initLoader(0, null, MediaOverviewActivity.this); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); - if (gridManager != null) gridManager.setSpanCount(getResources().getInteger(R.integer.media_overview_cols)); - } - - @TargetApi(VERSION_CODES.JELLY_BEAN) - private void setFullscreenIfPossible() { - getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, - WindowManager.LayoutParams.FLAG_FULLSCREEN); - - if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { - getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN); + if (gridManager != null) { + this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols)); + this.gridView.setLayoutManager(gridManager); } } @Override public void onResume() { super.onResume(); + dynamicTheme.onResume(this); dynamicLanguage.onResume(this); } private void initializeActionBar() { - getSupportActionBar().setTitle(recipient == null - ? getString(R.string.AndroidManifest__all_media) - : getString(R.string.AndroidManifest__all_media_named, recipient.toShortString())); - } - - @Override - public void onPause() { - super.onPause(); + getSupportActionBar().setTitle(recipient.toShortString()); } private void initializeResources() { - noImages = (TextView ) findViewById(R.id.no_images ); - gridView = (RecyclerView) findViewById(R.id.media_grid); - gridManager = new GridLayoutManager(this, getResources().getInteger(R.integer.media_overview_cols)); - gridView.setLayoutManager(gridManager); - gridView.setHasFixedSize(true); + this.noImages = ViewUtil.findById(this, R.id.no_images); + this.gridView = ViewUtil.findById(this, R.id.media_grid); + this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols)); Address address = getIntent().getParcelableExtra(ADDRESS_EXTRA); - if (address != null) { - recipient = Recipient.from(this, address, true); - } else { - recipient = null; - } + this.recipient = Recipient.from(this, address, true); + this.recipient.addListener(recipient -> initializeActionBar()); - if (recipient != null) { - recipient.addListener(new RecipientModifiedListener() { - @Override - public void onModified(Recipient recipients) { - initializeActionBar(); - } - }); - } + this.gridView.setAdapter(new MediaAdapter(this, masterSecret, new BucketedThreadMedia(this), dynamicLanguage.getCurrentLocale(), address)); + this.gridView.setLayoutManager(gridManager); + this.gridView.setHasFixedSize(true); } private void saveToDisk() { @@ -212,21 +187,21 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity i } @Override - public Loader onCreateLoader(int i, Bundle bundle) { - return new ThreadMediaLoader(this, masterSecret, recipient.getAddress()); + public Loader onCreateLoader(int i, Bundle bundle) { + return new BucketedThreadMediaLoader(this, masterSecret, recipient.getAddress()); } @Override - public void onLoadFinished(Loader cursorLoader, Cursor cursor) { - Log.w(TAG, "onLoadFinished()"); - gridView.setAdapter(new MediaAdapter(this, masterSecret, cursor, recipient.getAddress())); + public void onLoadFinished(Loader loader, BucketedThreadMedia bucketedThreadMedia) { + ((MediaAdapter)gridView.getAdapter()).setMedia(bucketedThreadMedia); + ((MediaAdapter)gridView.getAdapter()).notifyAllSectionsDataSetChanged(); + noImages.setVisibility(gridView.getAdapter().getItemCount() > 0 ? View.GONE : View.VISIBLE); invalidateOptionsMenu(); } @Override - public void onLoaderReset(Loader cursorLoader) { - ((CursorRecyclerViewAdapter)gridView.getAdapter()).changeCursor(null); + public void onLoaderReset(Loader cursorLoader) { + ((MediaAdapter)gridView.getAdapter()).setMedia(new BucketedThreadMedia(this)); } - } diff --git a/src/org/thoughtcrime/securesms/database/loaders/BucketedThreadMediaLoader.java b/src/org/thoughtcrime/securesms/database/loaders/BucketedThreadMediaLoader.java new file mode 100644 index 0000000000..4d4f85942b --- /dev/null +++ b/src/org/thoughtcrime/securesms/database/loaders/BucketedThreadMediaLoader.java @@ -0,0 +1,221 @@ +package org.thoughtcrime.securesms.database.loaders; + + +import android.content.Context; +import android.database.Cursor; +import android.support.annotation.NonNull; +import android.support.v4.content.AsyncTaskLoader; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class BucketedThreadMediaLoader extends AsyncTaskLoader { + + private static final String TAG = BucketedThreadMediaLoader.class.getSimpleName(); + + private final MasterSecret masterSecret; + private final Address address; + + public BucketedThreadMediaLoader(@NonNull Context context, @NonNull MasterSecret masterSecret, @NonNull Address address) { + super(context); + this.masterSecret = masterSecret; + this.address = address; + + onContentChanged(); + } + + @Override + protected void onStartLoading() { + if (takeContentChanged()) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + public BucketedThreadMedia loadInBackground() { + BucketedThreadMedia result = new BucketedThreadMedia(getContext()); + long threadId = DatabaseFactory.getThreadDatabase(getContext()).getThreadIdFor(Recipient.from(getContext(), address, true)); + + try (Cursor cursor = DatabaseFactory.getMediaDatabase(getContext()).getMediaForThread(threadId)) { + while (cursor != null && cursor.moveToNext()) { + result.add(MediaDatabase.MediaRecord.from(getContext(), masterSecret, cursor)); + } + } + + return result; + } + + public static class BucketedThreadMedia { + + private final TimeBucket TODAY; + private final TimeBucket YESTERDAY; + private final TimeBucket THIS_WEEK; + private final TimeBucket THIS_MONTH; + private final MonthBuckets OLDER; + + private final TimeBucket[] TIME_SECTIONS; + + public BucketedThreadMedia(@NonNull Context context) { + this.TODAY = new TimeBucket(context.getString(R.string.BucketedThreadMedia_Today), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -1), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, 1000)); + this.YESTERDAY = new TimeBucket(context.getString(R.string.BucketedThreadMedia_Yesterday), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -2), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -1)); + this.THIS_WEEK = new TimeBucket(context.getString(R.string.BucketedThreadMedia_This_week), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -7), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -2)); + this.THIS_MONTH = new TimeBucket(context.getString(R.string.BucketedThreadMedia_This_month), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -30), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -7)); + this.TIME_SECTIONS = new TimeBucket[]{TODAY, YESTERDAY, THIS_WEEK, THIS_MONTH}; + this.OLDER = new MonthBuckets(); + } + + + public void add(MediaDatabase.MediaRecord mediaRecord) { + for (TimeBucket timeSection : TIME_SECTIONS) { + if (timeSection.inRange(mediaRecord.getDate())) { + timeSection.add(mediaRecord); + return; + } + } + + OLDER.add(mediaRecord); + } + + public int getSectionCount() { + return (int)Stream.of(TIME_SECTIONS) + .filter(timeBucket -> !timeBucket.isEmpty()) + .count() + + OLDER.getSectionCount(); + } + + public int getSectionItemCount(int section) { + List activeTimeBuckets = Stream.of(TIME_SECTIONS).filter(timeBucket -> !timeBucket.isEmpty()).toList(); + + if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getItemCount(); + else return OLDER.getSectionItemCount(section - activeTimeBuckets.size()); + } + + public MediaDatabase.MediaRecord get(int section, int item) { + List activeTimeBuckets = Stream.of(TIME_SECTIONS).filter(timeBucket -> !timeBucket.isEmpty()).toList(); + + if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getItem(item); + else return OLDER.getItem(section - activeTimeBuckets.size(), item); + } + + public String getName(int section, Locale locale) { + List activeTimeBuckets = Stream.of(TIME_SECTIONS).filter(timeBucket -> !timeBucket.isEmpty()).toList(); + + if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getName(); + else return OLDER.getName(section - activeTimeBuckets.size(), locale); + } + + private static class TimeBucket { + + private final List records = new LinkedList<>(); + + private final long startTime; + private final long endtime; + private final String name; + + TimeBucket(String name, long startTime, long endtime) { + this.name = name; + this.startTime = startTime; + this.endtime = endtime; + } + + void add(MediaDatabase.MediaRecord record) { + this.records.add(record); + } + + boolean inRange(long timestamp) { + return timestamp > startTime && timestamp <= endtime; + } + + boolean isEmpty() { + return records.isEmpty(); + } + + int getItemCount() { + return records.size(); + } + + MediaDatabase.MediaRecord getItem(int position) { + return records.get(position); + } + + String getName() { + return name; + } + + static long addToCalendar(int field, int amount) { + Calendar calendar = Calendar.getInstance(); + calendar.add(field, amount); + return calendar.getTimeInMillis(); + } + } + + private static class MonthBuckets { + + private final Map> months = new HashMap<>(); + + void add(MediaDatabase.MediaRecord record) { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(record.getDate()); + + int year = calendar.get(Calendar.YEAR) - 1900; + int month = calendar.get(Calendar.MONTH); + Date date = new Date(year, month, 1); + + if (months.containsKey(date)) { + months.get(date).add(record); + } else { + List list = new LinkedList<>(); + list.add(record); + months.put(date, list); + } + } + + int getSectionCount() { + return months.size(); + } + + int getSectionItemCount(int section) { + return months.get(getSection(section)).size(); + } + + MediaDatabase.MediaRecord getItem(int section, int position) { + return months.get(getSection(section)).get(position); + } + + Date getSection(int section) { + ArrayList keys = new ArrayList<>(months.keySet()); + Collections.sort(keys, Collections.reverseOrder()); + + return keys.get(section); + } + + String getName(int section, Locale locale) { + Date sectionDate = getSection(section); + + return new SimpleDateFormat("MMMM, yyyy", locale).format(sectionDate); + } + } + } +}