Reorganize conversation media activity to have sticky headers

// FREEBIE
master
Moxie Marlinspike 2017-09-23 21:30:00 -07:00
parent 5189fbf686
commit c6b2e785a5
8 changed files with 349 additions and 90 deletions

View File

@ -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',

View File

@ -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">
<android.support.v7.widget.RecyclerView
android:id="@+id/media_grid"
@ -14,11 +14,9 @@
<TextView android:id="@+id/no_images"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:textStyle="italic"
android:layout_height="match_parent"
android:textSize="24sp"
android:gravity="center_horizontal"
android:gravity="center"
android:paddingTop="30dp"
android:visibility="gone"
android:text="@string/media_overview_activity__no_media" />

View File

@ -2,7 +2,8 @@
<org.thoughtcrime.securesms.components.SquareFrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:padding="2dp">
<org.thoughtcrime.securesms.components.ThumbnailView
android:id="@+id/image"

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:padding="16dp">
<TextView android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:textColor="@color/gray50"
android:textSize="14sp"
android:textStyle="bold"
android:textAllCaps="true"
tools:text="March 1, 2015" />
</FrameLayout>

View File

@ -1469,6 +1469,10 @@
<string name="RingtonePreference_alarm_sound_default">Default alarm sound</string>
<string name="RingtonePreference_add_ringtone_text">Add ringtone</string>
<string name="RingtonePreference_unable_to_add_ringtone">Unable to add custom ringtone</string>
<string name="BucketedThreadMedia_Today">Today</string>
<string name="BucketedThreadMedia_Yesterday">Yesterday</string>
<string name="BucketedThreadMedia_This_week">This week</string>
<string name="BucketedThreadMedia_This_month">This month</string>
<!-- EOF -->

View File

@ -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<ViewHolder> {
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;
public static class ViewHolder extends RecyclerView.ViewHolder {
public ThumbnailView imageView;
private BucketedThreadMedia media;
public ViewHolder(View v) {
private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder {
ThumbnailView imageView;
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;
}
@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 void setMedia(BucketedThreadMedia media) {
this.media = media;
}
@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 StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) {
return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_item_header, parent, false));
}
Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment());
@Override
public ItemViewHolder onCreateItemViewHolder(ViewGroup parent, int itemType) {
return new ViewHolder(LayoutInflater.from(context).inflate(R.layout.media_overview_item, parent, false));
}
@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));
}
private class OnMediaClickListener implements OnClickListener {
@Override
public int getSectionCount() {
return media.getSectionCount();
}
@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<ViewHolder> {
@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<ViewHolder> {
}
intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType());
getContext().startActivity(intent);
context.startActivity(intent);
}
}
}
}

View File

@ -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<Cursor> {
public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity implements LoaderManager.LoaderCallbacks<BucketedThreadMedia> {
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<Cursor> onCreateLoader(int i, Bundle bundle) {
return new ThreadMediaLoader(this, masterSecret, recipient.getAddress());
public Loader<BucketedThreadMedia> onCreateLoader(int i, Bundle bundle) {
return new BucketedThreadMediaLoader(this, masterSecret, recipient.getAddress());
}
@Override
public void onLoadFinished(Loader<Cursor> cursorLoader, Cursor cursor) {
Log.w(TAG, "onLoadFinished()");
gridView.setAdapter(new MediaAdapter(this, masterSecret, cursor, recipient.getAddress()));
public void onLoadFinished(Loader<BucketedThreadMedia> 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<Cursor> cursorLoader) {
((CursorRecyclerViewAdapter)gridView.getAdapter()).changeCursor(null);
public void onLoaderReset(Loader<BucketedThreadMedia> cursorLoader) {
((MediaAdapter)gridView.getAdapter()).setMedia(new BucketedThreadMedia(this));
}
}

View File

@ -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<BucketedThreadMediaLoader.BucketedThreadMedia> {
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<TimeBucket> 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<TimeBucket> 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<TimeBucket> 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<MediaDatabase.MediaRecord> 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<Date, List<MediaDatabase.MediaRecord>> 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<MediaDatabase.MediaRecord> 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<Date> 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);
}
}
}
}