diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 44275e9d6..9ec840247 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -266,6 +266,11 @@ android:windowSoftInputMode="stateHidden" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> + + diff --git a/build.gradle b/build.gradle index e96c363d3..0e4ed3c78 100644 --- a/build.gradle +++ b/build.gradle @@ -172,6 +172,8 @@ android { buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L" buildConfigField "String", "TEXTSECURE_URL", "\"https://textsecure-service.whispersystems.org\"" + buildConfigField "String", "GIPHY_PROXY_HOST", "\"giphy-proxy-production.whispersystems.org\"" + buildConfigField "int", "GIPHY_PROXY_PORT", "80" buildConfigField "String", "USER_AGENT", "\"OWA\"" buildConfigField "String", "REDPHONE_MASTER_URL", "\"https://redphone-master.whispersystems.org\"" buildConfigField "String", "REDPHONE_RELAY_HOST", "\"relay.whispersystems.org\"" diff --git a/proguard-appcompat-v7.pro b/proguard-appcompat-v7.pro index 718eb9da9..f0d673934 100644 --- a/proguard-appcompat-v7.pro +++ b/proguard-appcompat-v7.pro @@ -7,3 +7,7 @@ -keep public class * extends android.support.v4.view.ActionProvider { public (android.content.Context); } + +-keepattributes *Annotation* +-keep public class * extends android.support.design.widget.CoordinatorLayout.Behavior { *; } +-keep public class * extends android.support.design.widget.ViewOffsetBehavior { *; } diff --git a/res/drawable-hdpi/ic_dashboard_white_24dp.png b/res/drawable-hdpi/ic_dashboard_white_24dp.png new file mode 100644 index 000000000..3208779f8 Binary files /dev/null and b/res/drawable-hdpi/ic_dashboard_white_24dp.png differ diff --git a/res/drawable-hdpi/ic_gif_white_36dp.png b/res/drawable-hdpi/ic_gif_white_36dp.png new file mode 100644 index 000000000..17b6ed692 Binary files /dev/null and b/res/drawable-hdpi/ic_gif_white_36dp.png differ diff --git a/res/drawable-hdpi/ic_view_stream_white_24dp.png b/res/drawable-hdpi/ic_view_stream_white_24dp.png new file mode 100644 index 000000000..857becfc2 Binary files /dev/null and b/res/drawable-hdpi/ic_view_stream_white_24dp.png differ diff --git a/res/drawable-hdpi/poweredby_giphy.png b/res/drawable-hdpi/poweredby_giphy.png new file mode 100644 index 000000000..df5e4f06e Binary files /dev/null and b/res/drawable-hdpi/poweredby_giphy.png differ diff --git a/res/drawable-mdpi/ic_dashboard_white_24dp.png b/res/drawable-mdpi/ic_dashboard_white_24dp.png new file mode 100644 index 000000000..1614347a8 Binary files /dev/null and b/res/drawable-mdpi/ic_dashboard_white_24dp.png differ diff --git a/res/drawable-mdpi/ic_gif_white_36dp.png b/res/drawable-mdpi/ic_gif_white_36dp.png new file mode 100644 index 000000000..0978a141a Binary files /dev/null and b/res/drawable-mdpi/ic_gif_white_36dp.png differ diff --git a/res/drawable-mdpi/ic_view_stream_white_24dp.png b/res/drawable-mdpi/ic_view_stream_white_24dp.png new file mode 100644 index 000000000..a0a663458 Binary files /dev/null and b/res/drawable-mdpi/ic_view_stream_white_24dp.png differ diff --git a/res/drawable-mdpi/poweredby_giphy.png b/res/drawable-mdpi/poweredby_giphy.png new file mode 100644 index 000000000..0cee864a3 Binary files /dev/null and b/res/drawable-mdpi/poweredby_giphy.png differ diff --git a/res/drawable-xhdpi/ic_dashboard_white_24dp.png b/res/drawable-xhdpi/ic_dashboard_white_24dp.png new file mode 100644 index 000000000..da1a5852c Binary files /dev/null and b/res/drawable-xhdpi/ic_dashboard_white_24dp.png differ diff --git a/res/drawable-xhdpi/ic_gif_white_36dp.png b/res/drawable-xhdpi/ic_gif_white_36dp.png new file mode 100644 index 000000000..acdb6d0b9 Binary files /dev/null and b/res/drawable-xhdpi/ic_gif_white_36dp.png differ diff --git a/res/drawable-xhdpi/ic_view_stream_white_24dp.png b/res/drawable-xhdpi/ic_view_stream_white_24dp.png new file mode 100644 index 000000000..9e640ae55 Binary files /dev/null and b/res/drawable-xhdpi/ic_view_stream_white_24dp.png differ diff --git a/res/drawable-xhdpi/poweredby_giphy.png b/res/drawable-xhdpi/poweredby_giphy.png new file mode 100644 index 000000000..09b38f190 Binary files /dev/null and b/res/drawable-xhdpi/poweredby_giphy.png differ diff --git a/res/drawable-xxhdpi/ic_dashboard_white_24dp.png b/res/drawable-xxhdpi/ic_dashboard_white_24dp.png new file mode 100644 index 000000000..471aa0db1 Binary files /dev/null and b/res/drawable-xxhdpi/ic_dashboard_white_24dp.png differ diff --git a/res/drawable-xxhdpi/ic_gif_white_36dp.png b/res/drawable-xxhdpi/ic_gif_white_36dp.png new file mode 100644 index 000000000..ae117123c Binary files /dev/null and b/res/drawable-xxhdpi/ic_gif_white_36dp.png differ diff --git a/res/drawable-xxhdpi/ic_view_stream_white_24dp.png b/res/drawable-xxhdpi/ic_view_stream_white_24dp.png new file mode 100644 index 000000000..b8656197f Binary files /dev/null and b/res/drawable-xxhdpi/ic_view_stream_white_24dp.png differ diff --git a/res/drawable-xxhdpi/poweredby_giphy.png b/res/drawable-xxhdpi/poweredby_giphy.png new file mode 100644 index 000000000..aca5bf661 Binary files /dev/null and b/res/drawable-xxhdpi/poweredby_giphy.png differ diff --git a/res/drawable-xxxhdpi/ic_dashboard_white_24dp.png b/res/drawable-xxxhdpi/ic_dashboard_white_24dp.png new file mode 100644 index 000000000..817f274cf Binary files /dev/null and b/res/drawable-xxxhdpi/ic_dashboard_white_24dp.png differ diff --git a/res/drawable-xxxhdpi/ic_gif_white_36dp.png b/res/drawable-xxxhdpi/ic_gif_white_36dp.png new file mode 100644 index 000000000..0ffbdb355 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_gif_white_36dp.png differ diff --git a/res/drawable-xxxhdpi/ic_view_stream_white_24dp.png b/res/drawable-xxxhdpi/ic_view_stream_white_24dp.png new file mode 100644 index 000000000..1e679697e Binary files /dev/null and b/res/drawable-xxxhdpi/ic_view_stream_white_24dp.png differ diff --git a/res/drawable-xxxhdpi/poweredby_giphy.png b/res/drawable-xxxhdpi/poweredby_giphy.png new file mode 100644 index 000000000..f266ba652 Binary files /dev/null and b/res/drawable-xxxhdpi/poweredby_giphy.png differ diff --git a/res/layout/attachment_type_selector.xml b/res/layout/attachment_type_selector.xml index 9b912d012..58723e996 100644 --- a/res/layout/attachment_type_selector.xml +++ b/res/layout/attachment_type_selector.xml @@ -169,6 +169,21 @@ android:gravity="center" android:orientation="vertical"> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/giphy_activity_toolbar.xml b/res/layout/giphy_activity_toolbar.xml new file mode 100644 index 000000000..1e5d1de53 --- /dev/null +++ b/res/layout/giphy_activity_toolbar.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/giphy_fragment.xml b/res/layout/giphy_fragment.xml new file mode 100644 index 000000000..9c364a25a --- /dev/null +++ b/res/layout/giphy_fragment.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/giphy_thumbnail.xml b/res/layout/giphy_thumbnail.xml new file mode 100644 index 000000000..eed7e09e1 --- /dev/null +++ b/res/layout/giphy_thumbnail.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml index d25ae0b6f..995f4eb1b 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -269,6 +269,14 @@ Permanent Signal communication failure! Signal was unable to register with Google Play Services. Signal messages and calls have been disabled, please try re-registering in Settings > Advanced. + + + Error while retrieving full resolution GiF... + + + GIFs + Stickers + New group Update group @@ -751,6 +759,13 @@ %dw + + Search GIFs and stickers + + + No results found. + + Could not read the log on your device. You can still use ADB to get a debug log instead. Thanks for your help! diff --git a/src/org/thoughtcrime/securesms/ConversationActivity.java b/src/org/thoughtcrime/securesms/ConversationActivity.java index 1a8e487d2..3bb9dcba6 100644 --- a/src/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/ConversationActivity.java @@ -92,14 +92,11 @@ import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.DraftDatabase.Draft; import org.thoughtcrime.securesms.database.DraftDatabase.Drafts; import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.MessagingDatabase; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; -import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.MmsSmsColumns.Types; import org.thoughtcrime.securesms.database.RecipientPreferenceDatabase.RecipientsPreferences; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; -import org.thoughtcrime.securesms.jobs.MultiDeviceReadUpdateJob; import org.thoughtcrime.securesms.mms.AttachmentManager; import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType; import org.thoughtcrime.securesms.mms.AttachmentTypeSelectorAdapter; @@ -187,6 +184,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private static final int TAKE_PHOTO = 6; private static final int ADD_CONTACT = 7; private static final int PICK_LOCATION = 8; + private static final int PICK_GIF = 9; private MasterSecret masterSecret; protected ComposeText composeText; @@ -371,6 +369,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity SignalPlace place = new SignalPlace(PlacePicker.getPlace(data, this)); attachmentManager.setLocation(masterSecret, place, getCurrentMediaConstraints()); break; + case PICK_GIF: + setMedia(data.getData(), MediaType.GIF); + break; } } @@ -1118,6 +1119,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity AttachmentManager.selectLocation(this, PICK_LOCATION); break; case AttachmentTypeSelectorAdapter.TAKE_PHOTO: attachmentManager.capturePhoto(this, TAKE_PHOTO); break; + case AttachmentTypeSelector.ADD_GIF: + AttachmentManager.selectGif(this, PICK_GIF); break; } } diff --git a/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java b/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java index 0898f51e6..1d6c017c9 100644 --- a/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java +++ b/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java @@ -34,6 +34,7 @@ public class AttachmentTypeSelector extends PopupWindow { public static final int ADD_CONTACT_INFO = 4; public static final int TAKE_PHOTO = 5; public static final int ADD_LOCATION = 6; + public static final int ADD_GIF = 7; private static final int ANIMATION_DURATION = 300; @@ -45,6 +46,7 @@ public class AttachmentTypeSelector extends PopupWindow { private final @NonNull ImageView contactButton; private final @NonNull ImageView cameraButton; private final @NonNull ImageView locationButton; + private final @NonNull ImageView gifButton; private final @NonNull ImageView closeButton; private @Nullable View currentAnchor; @@ -62,8 +64,9 @@ public class AttachmentTypeSelector extends PopupWindow { this.videoButton = ViewUtil.findById(layout, R.id.video_button); this.contactButton = ViewUtil.findById(layout, R.id.contact_button); this.cameraButton = ViewUtil.findById(layout, R.id.camera_button); - this.closeButton = ViewUtil.findById(layout, R.id.close_button); this.locationButton = ViewUtil.findById(layout, R.id.location_button); + this.gifButton = ViewUtil.findById(layout, R.id.giphy_button); + this.closeButton = ViewUtil.findById(layout, R.id.close_button); this.imageButton.setOnClickListener(new PropagatingClickListener(ADD_IMAGE)); this.audioButton.setOnClickListener(new PropagatingClickListener(ADD_SOUND)); @@ -71,6 +74,7 @@ public class AttachmentTypeSelector extends PopupWindow { this.contactButton.setOnClickListener(new PropagatingClickListener(ADD_CONTACT_INFO)); this.cameraButton.setOnClickListener(new PropagatingClickListener(TAKE_PHOTO)); this.locationButton.setOnClickListener(new PropagatingClickListener(ADD_LOCATION)); + this.gifButton.setOnClickListener(new PropagatingClickListener(ADD_GIF)); this.closeButton.setOnClickListener(new CloseClickListener()); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { @@ -112,6 +116,7 @@ public class AttachmentTypeSelector extends PopupWindow { animateButtonIn(audioButton, ANIMATION_DURATION / 3); animateButtonIn(locationButton, ANIMATION_DURATION / 3); animateButtonIn(videoButton, ANIMATION_DURATION / 4); + animateButtonIn(gifButton, ANIMATION_DURATION / 4); animateButtonIn(contactButton, 0); animateButtonIn(closeButton, 0); } diff --git a/src/org/thoughtcrime/securesms/components/ThumbnailView.java b/src/org/thoughtcrime/securesms/components/ThumbnailView.java index e75a8b8d3..29799b548 100644 --- a/src/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/src/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -18,6 +18,7 @@ import android.widget.ImageView; import com.bumptech.glide.DrawableRequestBuilder; import com.bumptech.glide.GenericRequestBuilder; import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.crypto.MasterSecret; @@ -127,7 +128,9 @@ public class ThumbnailView extends FrameLayout { public void setImageResource(@NonNull MasterSecret masterSecret, @NonNull Uri uri) { if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE); - Glide.with(getContext()).load(new DecryptableUri(masterSecret, uri)) + Glide.with(getContext()) + .load(new DecryptableUri(masterSecret, uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) .crossFade() .transform(new RoundedCorners(getContext(), true, radius, backgroundColorHint)) .into(image); @@ -161,18 +164,22 @@ public class ThumbnailView extends FrameLayout { private GenericRequestBuilder buildThumbnailGlideRequest(@NonNull Slide slide, @NonNull MasterSecret masterSecret) { @SuppressWarnings("ConstantConditions") - DrawableRequestBuilder builder = Glide.with(getContext()).load(new DecryptableUri(masterSecret, slide.getThumbnailUri())) - .crossFade() - .transform(new RoundedCorners(getContext(), true, radius, backgroundColorHint)); + DrawableRequestBuilder builder = Glide.with(getContext()) + .load(new DecryptableUri(masterSecret, slide.getThumbnailUri())) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .crossFade() + .transform(new RoundedCorners(getContext(), true, radius, backgroundColorHint)); if (slide.isInProgress()) return builder; else return builder.error(R.drawable.ic_missing_thumbnail_picture); } private GenericRequestBuilder buildPlaceholderGlideRequest(Slide slide) { - return Glide.with(getContext()).load(slide.getPlaceholderRes(getContext().getTheme())) - .asBitmap() - .fitCenter(); + return Glide.with(getContext()) + .load(slide.getPlaceholderRes(getContext().getTheme())) + .asBitmap() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .fitCenter(); } private class ThumbnailClickDispatcher implements View.OnClickListener { diff --git a/src/org/thoughtcrime/securesms/components/ZoomingImageView.java b/src/org/thoughtcrime/securesms/components/ZoomingImageView.java index 1caf6d4bf..03947b42e 100644 --- a/src/org/thoughtcrime/securesms/components/ZoomingImageView.java +++ b/src/org/thoughtcrime/securesms/components/ZoomingImageView.java @@ -7,6 +7,7 @@ import android.util.AttributeSet; import android.widget.ImageView; import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.drawable.GlideDrawable; import com.bumptech.glide.request.target.BitmapImageViewTarget; import com.bumptech.glide.request.target.GlideDrawableImageViewTarget; @@ -34,6 +35,7 @@ public class ZoomingImageView extends ImageView { public void setImageUri(MasterSecret masterSecret, Uri uri) { Glide.with(getContext()) .load(new DecryptableUri(masterSecret, uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) .dontTransform() .dontAnimate() .into(new GlideDrawableImageViewTarget(this) { diff --git a/src/org/thoughtcrime/securesms/contacts/avatars/ContactPhotoFactory.java b/src/org/thoughtcrime/securesms/contacts/avatars/ContactPhotoFactory.java index 833fa4ed0..ccb54d433 100644 --- a/src/org/thoughtcrime/securesms/contacts/avatars/ContactPhotoFactory.java +++ b/src/org/thoughtcrime/securesms/contacts/avatars/ContactPhotoFactory.java @@ -10,6 +10,7 @@ import android.support.annotation.Nullable; import android.text.TextUtils; import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.mms.ContactPhotoUriLoader.ContactPhotoUri; @@ -52,6 +53,7 @@ public class ContactPhotoFactory { try { Bitmap bitmap = Glide.with(context) .load(new ContactPhotoUri(uri)).asBitmap() + .diskCacheStrategy(DiskCacheStrategy.NONE) .centerCrop().into(targetSize, targetSize).get(); return new BitmapContactPhoto(bitmap); } catch (ExecutionException e) { diff --git a/src/org/thoughtcrime/securesms/giph/model/GiphyImage.java b/src/org/thoughtcrime/securesms/giph/model/GiphyImage.java new file mode 100644 index 000000000..b804e9750 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/model/GiphyImage.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.giph.model; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class GiphyImage { + + @JsonProperty + private ImageTypes images; + + public String getGifUrl() { + return images.downsized_medium.url; + } + + public float getGifAspectRatio() { + return (float)images.downsized_medium.width / (float)images.downsized_medium.height; + } + + public String getStillUrl() { + return images.fixed_width_still.url; + } + + public static class ImageTypes { + @JsonProperty + private ImageData fixed_height; + @JsonProperty + private ImageData fixed_height_still; + @JsonProperty + private ImageData fixed_height_downsampled; + @JsonProperty + private ImageData fixed_width; + @JsonProperty + private ImageData fixed_width_still; + @JsonProperty + private ImageData fixed_width_downsampled; + @JsonProperty + private ImageData fixed_width_small; + @JsonProperty + private ImageData downsized_medium; + } + + public static class ImageData { + @JsonProperty + private String url; + + @JsonProperty + private int width; + + @JsonProperty + private int height; + + @JsonProperty + private int size; + + @JsonProperty + private String mp4; + + @JsonProperty + private String webp; + } + +} diff --git a/src/org/thoughtcrime/securesms/giph/model/GiphyResponse.java b/src/org/thoughtcrime/securesms/giph/model/GiphyResponse.java new file mode 100644 index 000000000..4ab61b571 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/model/GiphyResponse.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.giph.model; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class GiphyResponse { + + @JsonProperty + private List data; + + public List getData() { + return data; + } + +} diff --git a/src/org/thoughtcrime/securesms/giph/net/GiphyGifLoader.java b/src/org/thoughtcrime/securesms/giph/net/GiphyGifLoader.java new file mode 100644 index 000000000..e832d23bf --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/net/GiphyGifLoader.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.giph.net; + + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +public class GiphyGifLoader extends GiphyLoader { + + public GiphyGifLoader(@NonNull Context context, @Nullable String searchString) { + super(context, searchString); + } + + @Override + protected String getTrendingUrl() { + return "https://api.giphy.com/v1/gifs/trending?api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE; + } + + @Override + protected String getSearchUrl() { + return "https://api.giphy.com/v1/gifs/search?api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE + "&q=%s"; + } +} diff --git a/src/org/thoughtcrime/securesms/giph/net/GiphyLoader.java b/src/org/thoughtcrime/securesms/giph/net/GiphyLoader.java new file mode 100644 index 000000000..3f6d02fe1 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/net/GiphyLoader.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.giph.net; + + +import android.content.Context; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Log; + +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.Request; +import com.squareup.okhttp.Response; + +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.model.GiphyResponse; +import org.thoughtcrime.securesms.util.AsyncLoader; +import org.thoughtcrime.securesms.util.JsonUtils; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +public abstract class GiphyLoader extends AsyncLoader> { + + private static final String TAG = GiphyLoader.class.getName(); + + public static int PAGE_SIZE = 100; + + @Nullable private String searchString; + + private final OkHttpClient client = new OkHttpClient(); + + protected GiphyLoader(@NonNull Context context, @Nullable String searchString) { + super(context); + this.searchString = searchString; + this.client.setProxySelector(new GiphyProxySelector()); + } + + @Override + public List loadInBackground() { + return loadPage(0); + } + + public List loadPage(int offset) { + try { + String url; + + if (TextUtils.isEmpty(searchString)) url = String.format(getTrendingUrl(), offset); + else url = String.format(getSearchUrl(), offset, Uri.encode(searchString)); + + Request request = new Request.Builder().url(url).build(); + Response response = client.newCall(request).execute(); + + if (!response.isSuccessful()) { + throw new IOException("Unexpected code " + response); + } + + GiphyResponse giphyResponse = JsonUtils.fromJson(response.body().byteStream(), GiphyResponse.class); + + return giphyResponse.getData(); + } catch (IOException e) { + Log.w(TAG, e); + return new LinkedList<>(); + } + } + + protected abstract String getTrendingUrl(); + protected abstract String getSearchUrl(); +} diff --git a/src/org/thoughtcrime/securesms/giph/net/GiphyProxySelector.java b/src/org/thoughtcrime/securesms/giph/net/GiphyProxySelector.java new file mode 100644 index 000000000..e7c64bd48 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/net/GiphyProxySelector.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.giph.net; + + +import android.os.AsyncTask; +import android.util.Log; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.util.Util; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +public class GiphyProxySelector extends ProxySelector { + + private static final String TAG = GiphyProxySelector.class.getSimpleName(); + + private final List EMPTY = new ArrayList<>(1); + private volatile List GIPHY = null; + + public GiphyProxySelector() { + EMPTY.add(Proxy.NO_PROXY); + + if (Util.isMainThread()) { + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + synchronized (GiphyProxySelector.this) { + initializeGiphyProxy(); + GiphyProxySelector.this.notifyAll(); + } + return null; + } + }.execute(); + } else { + initializeGiphyProxy(); + } + } + + @Override + public List select(URI uri) { + if (uri.getHost().endsWith("giphy.com")) return getOrCreateGiphyProxy(); + else return EMPTY; + } + + @Override + public void connectFailed(URI uri, SocketAddress address, IOException failure) { + Log.w(TAG, failure); + } + + private void initializeGiphyProxy() { + GIPHY = new ArrayList(1) {{ + add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(BuildConfig.GIPHY_PROXY_HOST, + BuildConfig.GIPHY_PROXY_PORT))); + }}; + } + + private List getOrCreateGiphyProxy() { + if (GIPHY == null) { + synchronized (this) { + while (GIPHY == null) Util.wait(this, 0); + } + } + + return GIPHY; + } + +} diff --git a/src/org/thoughtcrime/securesms/giph/net/GiphyStickerLoader.java b/src/org/thoughtcrime/securesms/giph/net/GiphyStickerLoader.java new file mode 100644 index 000000000..9290fc2da --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/net/GiphyStickerLoader.java @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.giph.net; + + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +public class GiphyStickerLoader extends GiphyLoader { + + public GiphyStickerLoader(@NonNull Context context, @Nullable String searchString) { + super(context, searchString); + } + + @Override + protected String getTrendingUrl() { + return "https://api.giphy.com/v1/stickers/trending?api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE; + } + + @Override + protected String getSearchUrl() { + return "https://api.giphy.com/v1/stickers/search?q=cat&api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE + "&q=%s"; + } +} diff --git a/src/org/thoughtcrime/securesms/giph/ui/AspectRatioImageView.java b/src/org/thoughtcrime/securesms/giph/ui/AspectRatioImageView.java new file mode 100644 index 000000000..a1ce36e23 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/ui/AspectRatioImageView.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2015 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.thoughtcrime.securesms.giph.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; + + +/** + * AspectRatioImageView maintains an aspect ratio by adjusting the width or height dimension. The + * aspect ratio (width to height ratio) and adjustment dimension can be configured. + */ +public class AspectRatioImageView extends ImageView { + + private static final float DEFAULT_ASPECT_RATIO = 1.0f; + private static final int DEFAULT_ADJUST_DIMENSION = 0; + // defined by attrs.xml enum + static final int ADJUST_DIMENSION_HEIGHT = 0; + static final int ADJUST_DIMENSION_WIDTH = 1; + + private double aspectRatio; // width to height ratio + private int dimensionToAdjust; // ADJUST_DIMENSION_HEIGHT or ADJUST_DIMENSION_WIDTH + + public AspectRatioImageView(Context context) { + this(context, null); + } + + public AspectRatioImageView(Context context, AttributeSet attrs) { + super(context, attrs); +// final TypedArray a = context.obtainStyledAttributes(attrs, +// R.styleable.tw__AspectRatioImageView); +// try { +// aspectRatio = a.getFloat(R.styleable.tw__AspectRatioImageView_tw__image_aspect_ratio, +// DEFAULT_ASPECT_RATIO); +// dimensionToAdjust +// = a.getInt(R.styleable.tw__AspectRatioImageView_tw__image_dimension_to_adjust, +// DEFAULT_ADJUST_DIMENSION); +// } finally { +// a.recycle(); +// } + } + + public double getAspectRatio() { + return aspectRatio; + } + + public int getDimensionToAdjust() { + return dimensionToAdjust; + } + + /** + * Sets the aspect ratio that should be respected during measurement. + * + * @param aspectRatio desired width to height ratio + */ + public void setAspectRatio(final double aspectRatio) { + this.aspectRatio = aspectRatio; + } + + /** + * Resets the size to 0. + */ + public void resetSize() { + if (getMeasuredWidth() == 0 && getMeasuredHeight() == 0) { + return; + } + measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY)); + layout(0, 0, 0, 0); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int width = getMeasuredWidth(); + int height = getMeasuredHeight(); + if (dimensionToAdjust == ADJUST_DIMENSION_HEIGHT) { + height = calculateHeight(width, aspectRatio); + } else { + width = calculateWidth(height, aspectRatio); + } + setMeasuredDimension(width, height); + } + + /** + * Returns the height that will satisfy the width to height aspect ratio, keeping the given + * width fixed. + */ + int calculateHeight(int width, double ratio) { + if (ratio == 0) { + return 0; + } + return (int) Math.round(width / ratio); + } + + /** + * Returns the width that will satisfy the width to height aspect ratio, keeping the given + * height fixed. + */ + int calculateWidth(int height, double ratio) { + return (int) Math.round(height * ratio); + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java b/src/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java new file mode 100644 index 000000000..11ae145fb --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/ui/GiphyActivity.java @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.view.ViewPager; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.concurrent.ExecutionException; + +public class GiphyActivity extends PassphraseRequiredActionBarActivity + implements GiphyActivityToolbar.OnLayoutChangedListener, + GiphyActivityToolbar.OnFilterChangedListener, + GiphyAdapter.OnItemClickListener +{ + + private static final String TAG = GiphyActivity.class.getSimpleName(); + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private GiphyGifFragment gifFragment; + private GiphyStickerFragment stickerFragment; + + private GiphyAdapter.GiphyViewHolder finishingImage; + + @Override + public void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + public void onCreate(Bundle bundle, @NonNull MasterSecret masterSecret) { + setContentView(R.layout.giphy_activity); + + initializeToolbar(); + initializeResources(); + } + + private void initializeToolbar() { + GiphyActivityToolbar toolbar = ViewUtil.findById(this, R.id.giphy_toolbar); + toolbar.setOnFilterChangedListener(this); + toolbar.setOnLayoutChangedListener(this); + + setSupportActionBar(toolbar); + + getSupportActionBar().setDisplayHomeAsUpEnabled(false); + getSupportActionBar().setDisplayShowTitleEnabled(false); + } + + private void initializeResources() { + ViewPager viewPager = ViewUtil.findById(this, R.id.giphy_pager); + TabLayout tabLayout = ViewUtil.findById(this, R.id.tab_layout); + + this.gifFragment = new GiphyGifFragment(); + this.stickerFragment = new GiphyStickerFragment(); + + gifFragment.setClickListener(this); + stickerFragment.setClickListener(this); + + viewPager.setAdapter(new GiphyFragmentPagerAdapter(this, getSupportFragmentManager(), + gifFragment, stickerFragment)); + tabLayout.setupWithViewPager(viewPager); + } + + @Override + public void onFilterChanged(String filter) { + this.gifFragment.setSearchString(filter); + this.stickerFragment.setSearchString(filter); + } + + @Override + public void onLayoutChanged(int type) { + this.gifFragment.setLayoutManager(type); + this.stickerFragment.setLayoutManager(type); + } + + @Override + public void onClick(final GiphyAdapter.GiphyViewHolder viewHolder) { + if (finishingImage != null) finishingImage.gifProgress.setVisibility(View.GONE); + finishingImage = viewHolder; + finishingImage.gifProgress.setVisibility(View.VISIBLE); + + new AsyncTask() { + @Override + protected Uri doInBackground(Void... params) { + try { + return Uri.fromFile(viewHolder.getFile()); + } catch (InterruptedException | ExecutionException e) { + Log.w(TAG, e); + return null; + } + } + + protected void onPostExecute(@Nullable Uri uri) { + if (uri == null) { + Toast.makeText(GiphyActivity.this, R.string.GiphyActivity_error_while_retrieving_full_resolution_gif, Toast.LENGTH_LONG).show(); + } else if (viewHolder == finishingImage) { + setResult(RESULT_OK, new Intent().setData(uri)); + finish(); + } else { + Log.w(TAG, "Resolved Uri is no longer the selected element..."); + } + } + }.execute(); + } + + private static class GiphyFragmentPagerAdapter extends FragmentPagerAdapter { + + private final Context context; + private final GiphyGifFragment gifFragment; + private final GiphyStickerFragment stickerFragment; + + private GiphyFragmentPagerAdapter(@NonNull Context context, + @NonNull FragmentManager fragmentManager, + @NonNull GiphyGifFragment gifFragment, + @NonNull GiphyStickerFragment stickerFragment) + { + super(fragmentManager); + this.context = context.getApplicationContext(); + this.gifFragment = gifFragment; + this.stickerFragment = stickerFragment; + } + + @Override + public Fragment getItem(int position) { + if (position == 0) return gifFragment; + else return stickerFragment; + } + + @Override + public int getCount() { + return 2; + } + + @Override + public CharSequence getPageTitle(int position) { + if (position == 0) return context.getString(R.string.GiphyFragmentPagerAdapter_gifs); + else return context.getString(R.string.GiphyFragmentPagerAdapter_stickers); + } + } + +} diff --git a/src/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java b/src/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java new file mode 100644 index 000000000..c940937b6 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java @@ -0,0 +1,174 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.content.Context; +import android.graphics.Rect; +import android.support.annotation.Nullable; +import android.support.v7.widget.Toolbar; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.TouchDelegate; +import android.view.View; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AnimatingToggle; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class GiphyActivityToolbar extends Toolbar { + + @Nullable private OnFilterChangedListener filterListener; + @Nullable private OnLayoutChangedListener layoutListener; + + private EditText searchText; + private AnimatingToggle toggle; + private ImageView action; + private ImageView listToggle; + private ImageView gridToggle; + private ImageView clearToggle; + private LinearLayout toggleContainer; + + public GiphyActivityToolbar(Context context) { + this(context, null); + } + + public GiphyActivityToolbar(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.toolbarStyle); + } + + public GiphyActivityToolbar(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.giphy_activity_toolbar, this); + + this.action = ViewUtil.findById(this, R.id.action_icon); + this.searchText = ViewUtil.findById(this, R.id.search_view); + this.toggle = ViewUtil.findById(this, R.id.button_toggle); + this.listToggle = ViewUtil.findById(this, R.id.view_stream); + this.gridToggle = ViewUtil.findById(this, R.id.view_grid); + this.clearToggle = ViewUtil.findById(this, R.id.search_clear); + this.toggleContainer = ViewUtil.findById(this, R.id.toggle_container); + + this.listToggle.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + displayTogglingView(gridToggle); + if (layoutListener != null) layoutListener.onLayoutChanged(OnLayoutChangedListener.LAYOUT_LIST); + } + }); + + this.gridToggle.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + displayTogglingView(listToggle); + if (layoutListener != null) layoutListener.onLayoutChanged(OnLayoutChangedListener.LAYOUT_GRID); + } + }); + + this.clearToggle.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + searchText.setText(""); + clearToggle.setVisibility(View.INVISIBLE); + } + }); + + this.searchText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + + @Override + public void afterTextChanged(Editable s) { + if (SearchUtil.isEmpty(searchText)) clearToggle.setVisibility(View.INVISIBLE); + else clearToggle.setVisibility(View.VISIBLE); + + notifyListener(); + } + }); + + expandTapArea(this, action); + expandTapArea(toggleContainer, gridToggle); + } + + @Override + public void setNavigationIcon(int resId) { + action.setImageResource(resId); + } + + public void clear() { + searchText.setText(""); + notifyListener(); + } + + public void setOnLayoutChangedListener(@Nullable OnLayoutChangedListener layoutListener) { + this.layoutListener = layoutListener; + } + + public void setOnFilterChangedListener(@Nullable OnFilterChangedListener filterListener) { + this.filterListener = filterListener; + } + + private void notifyListener() { + if (filterListener != null) filterListener.onFilterChanged(searchText.getText().toString()); + } + + private void displayTogglingView(View view) { + toggle.display(view); + expandTapArea(toggleContainer, view); + } + + private void expandTapArea(final View container, final View child) { + final int padding = getResources().getDimensionPixelSize(R.dimen.contact_selection_actions_tap_area); + + container.post(new Runnable() { + @Override + public void run() { + Rect rect = new Rect(); + child.getHitRect(rect); + + rect.top -= padding; + rect.left -= padding; + rect.right += padding; + rect.bottom += padding; + + container.setTouchDelegate(new TouchDelegate(rect, child)); + } + }); + } + + private static class SearchUtil { + public static boolean isTextInput(EditText editText) { + return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT; + } + + public static boolean isPhoneInput(EditText editText) { + return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_PHONE; + } + + public static boolean isEmpty(EditText editText) { + return editText.getText().length() <= 0; + } + } + + public interface OnFilterChangedListener { + void onFilterChanged(String filter); + } + + public interface OnLayoutChangedListener { + public static final int LAYOUT_GRID = 1; + public static final int LAYOUT_LIST = 2; + void onLayoutChanged(int type); + } + + +} diff --git a/src/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java b/src/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java new file mode 100644 index 000000000..56cc21d28 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/ui/GiphyAdapter.java @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; + +import com.bumptech.glide.DrawableRequestBuilder; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.drawable.GlideDrawable; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.io.File; +import java.util.List; +import java.util.concurrent.ExecutionException; + + +public class GiphyAdapter extends RecyclerView.Adapter { + + private static final String TAG = GiphyAdapter.class.getSimpleName(); + + private List images; + private Context context; + private OnItemClickListener listener; + + class GiphyViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, RequestListener { + + public AspectRatioImageView thumbnail; + public GiphyImage image; + public ProgressBar gifProgress; + public volatile boolean modelReady; + + GiphyViewHolder(View view) { + super(view); + thumbnail = ViewUtil.findById(view, R.id.thumbnail); + gifProgress = ViewUtil.findById(view, R.id.gif_progress); + thumbnail.setOnClickListener(this); + gifProgress.setVisibility(View.GONE); + } + + @Override + public void onClick(View v) { + if (listener != null) listener.onClick(this); + } + + @Override + public boolean onException(Exception e, String model, Target target, boolean isFirstResource) { + Log.w(TAG, e); + + synchronized (this) { + if (image.getGifUrl().equals(model)) { + this.modelReady = true; + notifyAll(); + } + } + + return false; + } + + @Override + public boolean onResourceReady(GlideDrawable resource, String model, Target target, boolean isFromMemoryCache, boolean isFirstResource) { + synchronized (this) { + if (image.getGifUrl().equals(model)) { + this.modelReady = true; + notifyAll(); + } + } + + return false; + } + + public File getFile() throws ExecutionException, InterruptedException { + synchronized (this) { + while (!modelReady) { + Util.wait(this, 0); + } + } + + return Glide.with(context) + .load(image.getGifUrl()) + .downloadOnly(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .get(); + } + } + + GiphyAdapter(Context context, List images) { + this.context = context; + this.images = images; + } + + public void setImages(List images) { + this.images = images; + notifyDataSetChanged(); + } + + public void addImages(List images) { + this.images.addAll(images); + notifyDataSetChanged(); + } + + @Override + public GiphyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.giphy_thumbnail, parent, false); + + return new GiphyViewHolder(itemView); + } + + @Override + public void onBindViewHolder(GiphyViewHolder holder, int position) { + GiphyImage image = images.get(position); + + holder.modelReady = false; + holder.image = image; + holder.thumbnail.setAspectRatio(image.getGifAspectRatio()); + holder.gifProgress.setVisibility(View.GONE); + + DrawableRequestBuilder thumbnailRequest = Glide.with(context) + .load(image.getStillUrl()); + + Glide.with(context) + .load(image.getGifUrl()) + .thumbnail(thumbnailRequest) + .placeholder(new ColorDrawable(Util.getRandomElement(MaterialColor.values()).toConversationColor(context))) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .listener(holder) + .into(holder.thumbnail); + } + + @Override + public int getItemCount() { + return images.size(); + } + + public void setListener(OnItemClickListener listener) { + this.listener = listener; + } + + public interface OnItemClickListener { + void onClick(GiphyViewHolder viewHolder); + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java b/src/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java new file mode 100644 index 000000000..d90065ced --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/ui/GiphyFragment.java @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.giph.ui; + +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.Loader; +import android.support.v7.widget.DefaultItemAnimator; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.StaggeredGridLayoutManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.net.GiphyLoader; +import org.thoughtcrime.securesms.giph.util.InfiniteScrollListener; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.LinkedList; +import java.util.List; + +public abstract class GiphyFragment extends Fragment implements LoaderManager.LoaderCallbacks>, GiphyAdapter.OnItemClickListener { + + private static final String TAG = GiphyFragment.class.getSimpleName(); + + private GiphyAdapter giphyAdapter; + private RecyclerView recyclerView; + private ProgressBar loadingProgress; + private TextView noResultsView; + private GiphyAdapter.OnItemClickListener listener; + + protected String searchString; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) { + ViewGroup container = ViewUtil.inflate(inflater, viewGroup, R.layout.giphy_fragment); + this.recyclerView = ViewUtil.findById(container, R.id.giphy_list); + this.loadingProgress = ViewUtil.findById(container, R.id.loading_progress); + this.noResultsView = ViewUtil.findById(container, R.id.no_results); + + return container; + } + + @Override + public void onActivityCreated(Bundle bundle) { + super.onActivityCreated(bundle); + + this.giphyAdapter = new GiphyAdapter(getActivity(), new LinkedList()); + this.giphyAdapter.setListener(this); + + this.recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + this.recyclerView.setItemAnimator(new DefaultItemAnimator()); + this.recyclerView.setAdapter(giphyAdapter); + this.recyclerView.addOnScrollListener(new GiphyScrollListener()); + + getLoaderManager().initLoader(0, null, this); + } + + @Override + public void onLoadFinished(Loader> loader, List data) { + this.loadingProgress.setVisibility(View.GONE); + + if (data.isEmpty()) noResultsView.setVisibility(View.VISIBLE); + else noResultsView.setVisibility(View.GONE); + + this.giphyAdapter.setImages(data); + } + + @Override + public void onLoaderReset(Loader> loader) { + noResultsView.setVisibility(View.GONE); + this.giphyAdapter.setImages(new LinkedList()); + } + + public void setLayoutManager(int type) { + if (type == GiphyActivityToolbar.OnLayoutChangedListener.LAYOUT_GRID) { + this.recyclerView.setLayoutManager(new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)); + } else { + this.recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + } + } + + public void setClickListener(GiphyAdapter.OnItemClickListener listener) { + this.listener = listener; + } + + public void setSearchString(@Nullable String searchString) { + this.searchString = searchString; + this.noResultsView.setVisibility(View.GONE); + this.getLoaderManager().restartLoader(0, null, this); + } + + @Override + public void onClick(GiphyAdapter.GiphyViewHolder viewHolder) { + if (listener != null) listener.onClick(viewHolder); + } + + private class GiphyScrollListener extends InfiniteScrollListener { + @Override + public void onLoadMore(final int currentPage) { + final Loader> loader = getLoaderManager().getLoader(0); + if (loader == null) return; + + new AsyncTask>() { + @Override + protected List doInBackground(Void... params) { + return ((GiphyLoader)loader).loadPage(currentPage * GiphyLoader.PAGE_SIZE); + } + + protected void onPostExecute(List images) { + giphyAdapter.addImages(images); + } + }.execute(); + } + } +} diff --git a/src/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java b/src/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java new file mode 100644 index 000000000..ea6b84597 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/ui/GiphyGifFragment.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.os.Bundle; +import android.support.v4.content.Loader; + +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.net.GiphyGifLoader; + +import java.util.List; + +public class GiphyGifFragment extends GiphyFragment { + + @Override + public Loader> onCreateLoader(int id, Bundle args) { + return new GiphyGifLoader(getActivity(), searchString); + } + +} diff --git a/src/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java b/src/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java new file mode 100644 index 000000000..27a03b326 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/ui/GiphyStickerFragment.java @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.giph.ui; + + +import android.os.Bundle; +import android.support.v4.content.Loader; + +import org.thoughtcrime.securesms.giph.model.GiphyImage; +import org.thoughtcrime.securesms.giph.net.GiphyStickerLoader; + +import java.util.List; + +public class GiphyStickerFragment extends GiphyFragment { + @Override + public Loader> onCreateLoader(int id, Bundle args) { + return new GiphyStickerLoader(getActivity(), searchString); + } +} diff --git a/src/org/thoughtcrime/securesms/giph/util/InfiniteScrollListener.java b/src/org/thoughtcrime/securesms/giph/util/InfiniteScrollListener.java new file mode 100644 index 000000000..ba82f7e0d --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/util/InfiniteScrollListener.java @@ -0,0 +1,48 @@ +// From https://gist.github.com/mipreamble/b6d4b3d65b0b4775a22e#file-recyclerviewpositionhelper-java + +package org.thoughtcrime.securesms.giph.util; + +import android.support.v7.widget.RecyclerView; + +public abstract class InfiniteScrollListener extends RecyclerView.OnScrollListener { + + public static String TAG = InfiniteScrollListener.class.getSimpleName(); + + private int previousTotal = 0; // The total number of items in the dataset after the last load + private boolean loading = true; // True if we are still waiting for the last set of data to load. + private int visibleThreshold = 5; // The minimum amount of items to have below your current scroll position before loading more. + + int firstVisibleItem, visibleItemCount, totalItemCount; + + private int currentPage = 1; + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + + RecyclerViewPositionHelper recyclerViewPositionHelper = RecyclerViewPositionHelper.createHelper(recyclerView); + + visibleItemCount = recyclerView.getChildCount(); + totalItemCount = recyclerViewPositionHelper.getItemCount(); + firstVisibleItem = recyclerViewPositionHelper.findFirstVisibleItemPosition(); + + if (loading) { + if (totalItemCount > previousTotal) { + loading = false; + previousTotal = totalItemCount; + } + } + if (!loading && (totalItemCount - visibleItemCount) + <= (firstVisibleItem + visibleThreshold)) { + // End has been reached + // Do something + currentPage++; + + onLoadMore(currentPage); + + loading = true; + } + } + + public abstract void onLoadMore(int currentPage); +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/giph/util/RecyclerViewPositionHelper.java b/src/org/thoughtcrime/securesms/giph/util/RecyclerViewPositionHelper.java new file mode 100644 index 000000000..e2a62ec17 --- /dev/null +++ b/src/org/thoughtcrime/securesms/giph/util/RecyclerViewPositionHelper.java @@ -0,0 +1,115 @@ +// From https://gist.github.com/mipreamble/b6d4b3d65b0b4775a22e#file-recyclerviewpositionhelper-java + +package org.thoughtcrime.securesms.giph.util; + + +import android.support.v7.widget.OrientationHelper; +import android.support.v7.widget.RecyclerView; +import android.view.View; + +public class RecyclerViewPositionHelper { + + final RecyclerView recyclerView; + final RecyclerView.LayoutManager layoutManager; + + RecyclerViewPositionHelper(RecyclerView recyclerView) { + this.recyclerView = recyclerView; + this.layoutManager = recyclerView.getLayoutManager(); + } + + public static RecyclerViewPositionHelper createHelper(RecyclerView recyclerView) { + if (recyclerView == null) { + throw new NullPointerException("Recycler View is null"); + } + return new RecyclerViewPositionHelper(recyclerView); + } + + /** + * Returns the adapter item count. + * + * @return The total number on items in a layout manager + */ + public int getItemCount() { + return layoutManager == null ? 0 : layoutManager.getItemCount(); + } + + /** + * Returns the adapter position of the first visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + * + * @return The adapter position of the first visible item or {@link RecyclerView#NO_POSITION} if + * there aren't any visible items. + */ + public int findFirstVisibleItemPosition() { + final View child = findOneVisibleChild(0, layoutManager.getChildCount(), false, true); + return child == null ? RecyclerView.NO_POSITION : recyclerView.getChildAdapterPosition(child); + } + + /** + * Returns the adapter position of the first fully visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + * + * @return The adapter position of the first fully visible item or + * {@link RecyclerView#NO_POSITION} if there aren't any visible items. + */ + public int findFirstCompletelyVisibleItemPosition() { + final View child = findOneVisibleChild(0, layoutManager.getChildCount(), true, false); + return child == null ? RecyclerView.NO_POSITION : recyclerView.getChildAdapterPosition(child); + } + + /** + * Returns the adapter position of the last visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + * + * @return The adapter position of the last visible view or {@link RecyclerView#NO_POSITION} if + * there aren't any visible items + */ + public int findLastVisibleItemPosition() { + final View child = findOneVisibleChild(layoutManager.getChildCount() - 1, -1, false, true); + return child == null ? RecyclerView.NO_POSITION : recyclerView.getChildAdapterPosition(child); + } + + /** + * Returns the adapter position of the last fully visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + * + * @return The adapter position of the last fully visible view or + * {@link RecyclerView#NO_POSITION} if there aren't any visible items. + */ + public int findLastCompletelyVisibleItemPosition() { + final View child = findOneVisibleChild(layoutManager.getChildCount() - 1, -1, true, false); + return child == null ? RecyclerView.NO_POSITION : recyclerView.getChildAdapterPosition(child); + } + + View findOneVisibleChild(int fromIndex, int toIndex, boolean completelyVisible, + boolean acceptPartiallyVisible) { + OrientationHelper helper; + if (layoutManager.canScrollVertically()) { + helper = OrientationHelper.createVerticalHelper(layoutManager); + } else { + helper = OrientationHelper.createHorizontalHelper(layoutManager); + } + + final int start = helper.getStartAfterPadding(); + final int end = helper.getEndAfterPadding(); + final int next = toIndex > fromIndex ? 1 : -1; + View partiallyVisible = null; + for (int i = fromIndex; i != toIndex; i += next) { + final View child = layoutManager.getChildAt(i); + final int childStart = helper.getDecoratedStart(child); + final int childEnd = helper.getDecoratedEnd(child); + if (childStart < end && childEnd > start) { + if (completelyVisible) { + if (childStart >= start && childEnd <= end) { + return child; + } else if (acceptPartiallyVisible && partiallyVisible == null) { + partiallyVisible = child; + } + } else { + return child; + } + } + } + return partiallyVisible; + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/glide/OkHttpStreamFetcher.java b/src/org/thoughtcrime/securesms/glide/OkHttpStreamFetcher.java new file mode 100644 index 000000000..fd4fbae5d --- /dev/null +++ b/src/org/thoughtcrime/securesms/glide/OkHttpStreamFetcher.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.glide; + +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.util.ContentLengthInputStream; +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.Request; +import com.squareup.okhttp.Response; +import com.squareup.okhttp.ResponseBody; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +/** + * Fetches an {@link InputStream} using the okhttp library. + */ +public class OkHttpStreamFetcher implements DataFetcher { + private final OkHttpClient client; + private final GlideUrl url; + private InputStream stream; + private ResponseBody responseBody; + + public OkHttpStreamFetcher(OkHttpClient client, GlideUrl url) { + this.client = client; + this.url = url; + } + + @Override + public InputStream loadData(Priority priority) throws Exception { + Request.Builder requestBuilder = new Request.Builder() + .url(url.toStringUrl()); + + for (Map.Entry headerEntry : url.getHeaders().entrySet()) { + String key = headerEntry.getKey(); + requestBuilder.addHeader(key, headerEntry.getValue()); + } + + Request request = requestBuilder.build(); + + Response response = client.newCall(request).execute(); + responseBody = response.body(); + if (!response.isSuccessful()) { + throw new IOException("Request failed with code: " + response.code()); + } + + long contentLength = responseBody.contentLength(); + stream = ContentLengthInputStream.obtain(responseBody.byteStream(), contentLength); + return stream; + } + + @Override + public void cleanup() { + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { + // Ignored + } + } + if (responseBody != null) { + try { + responseBody.close(); + } catch (IOException e) { + // Ignored. + } + } + } + + @Override + public String getId() { + return url.getCacheKey(); + } + + @Override + public void cancel() { + // TODO: call cancel on the client when this method is called on a background thread. See #257 + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java b/src/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java new file mode 100644 index 000000000..948b765f7 --- /dev/null +++ b/src/org/thoughtcrime/securesms/glide/OkHttpUrlLoader.java @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.glide; + +import android.content.Context; + +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.GenericLoaderFactory; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.squareup.okhttp.OkHttpClient; + +import org.thoughtcrime.securesms.giph.net.GiphyProxySelector; + +import java.io.InputStream; + +/** + * A simple model loader for fetching media over http/https using OkHttp. + */ +public class OkHttpUrlLoader implements ModelLoader { + + /** + * The default factory for {@link OkHttpUrlLoader}s. + */ + public static class Factory implements ModelLoaderFactory { + private static volatile OkHttpClient internalClient; + private OkHttpClient client; + + private static OkHttpClient getInternalClient() { + if (internalClient == null) { + synchronized (Factory.class) { + if (internalClient == null) { + internalClient = new OkHttpClient(); + internalClient.setProxySelector(new GiphyProxySelector()); + } + } + } + return internalClient; + } + + /** + * Constructor for a new Factory that runs requests using a static singleton client. + */ + public Factory() { + this(getInternalClient()); + } + + /** + * Constructor for a new Factory that runs requests using given client. + */ + private Factory(OkHttpClient client) { + this.client = client; + } + + @Override + public ModelLoader build(Context context, GenericLoaderFactory factories) { + return new OkHttpUrlLoader(client); + } + + @Override + public void teardown() { + // Do nothing, this instance doesn't own the client. + } + } + + private final OkHttpClient client; + + private OkHttpUrlLoader(OkHttpClient client) { + this.client = client; + } + + @Override + public DataFetcher getResourceFetcher(GlideUrl model, int width, int height) { + return new OkHttpStreamFetcher(client, model); + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java index e5e0c401f..e6e9f9b19 100644 --- a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.components.location.SignalMapView; import org.thoughtcrime.securesms.components.location.SignalPlace; import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.giph.ui.GiphyActivity; import org.thoughtcrime.securesms.providers.PersistentBlobProvider; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.MediaUtil; @@ -261,6 +262,11 @@ public class AttachmentManager { } } + public static void selectGif(Activity activity, int requestCode) { + Intent intent = new Intent(activity, GiphyActivity.class); + activity.startActivityForResult(intent, requestCode); + } + private @Nullable Uri getSlideUri() { return slide.isPresent() ? slide.get().getUri() : null; } diff --git a/src/org/thoughtcrime/securesms/mms/TextSecureGlideModule.java b/src/org/thoughtcrime/securesms/mms/TextSecureGlideModule.java index d7f81ba33..acb59a9e0 100644 --- a/src/org/thoughtcrime/securesms/mms/TextSecureGlideModule.java +++ b/src/org/thoughtcrime/securesms/mms/TextSecureGlideModule.java @@ -6,8 +6,10 @@ import com.bumptech.glide.Glide; import com.bumptech.glide.GlideBuilder; import com.bumptech.glide.load.engine.cache.DiskCache; import com.bumptech.glide.load.engine.cache.DiskCacheAdapter; +import com.bumptech.glide.load.model.GlideUrl; import com.bumptech.glide.module.GlideModule; +import org.thoughtcrime.securesms.glide.OkHttpUrlLoader; import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel; import org.thoughtcrime.securesms.mms.ContactPhotoUriLoader.ContactPhotoUri; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; @@ -17,7 +19,7 @@ import java.io.InputStream; public class TextSecureGlideModule implements GlideModule { @Override public void applyOptions(Context context, GlideBuilder builder) { - builder.setDiskCache(new NoopDiskCacheFactory()); +// builder.setDiskCache(new NoopDiskCacheFactory()); } @Override @@ -25,6 +27,7 @@ public class TextSecureGlideModule implements GlideModule { glide.register(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory()); glide.register(ContactPhotoUri.class, InputStream.class, new ContactPhotoUriLoader.Factory()); glide.register(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory()); + glide.register(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); } public static class NoopDiskCacheFactory implements DiskCache.Factory { diff --git a/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 093269eb0..3987c8dd1 100644 --- a/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -16,6 +16,7 @@ import android.support.v4.app.RemoteInput; import android.text.SpannableStringBuilder; import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.crypto.MasterSecret; @@ -200,6 +201,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil return Glide.with(context) .load(new DecryptableStreamUriLoader.DecryptableUri(masterSecret, uri)) .asBitmap() + .diskCacheStrategy(DiskCacheStrategy.NONE) .into(500, 500) .get(); } catch (InterruptedException | ExecutionException e) { diff --git a/src/org/thoughtcrime/securesms/util/JsonUtils.java b/src/org/thoughtcrime/securesms/util/JsonUtils.java index 284d0a9e1..4d4deef19 100644 --- a/src/org/thoughtcrime/securesms/util/JsonUtils.java +++ b/src/org/thoughtcrime/securesms/util/JsonUtils.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.Reader; public class JsonUtils { @@ -23,7 +24,11 @@ public class JsonUtils { return objectMapper.readValue(serialized, clazz); } - public static T fromJson(InputStreamReader serialized, Class clazz) throws IOException { + public static T fromJson(InputStream serialized, Class clazz) throws IOException { + return objectMapper.readValue(serialized, clazz); + } + + public static T fromJson(Reader serialized, Class clazz) throws IOException { return objectMapper.readValue(serialized, clazz); } diff --git a/src/org/thoughtcrime/securesms/util/Util.java b/src/org/thoughtcrime/securesms/util/Util.java index f826de517..6a776ba3f 100644 --- a/src/org/thoughtcrime/securesms/util/Util.java +++ b/src/org/thoughtcrime/securesms/util/Util.java @@ -384,6 +384,14 @@ public class Util { } } + public static T getRandomElement(T[] elements) { + try { + return elements[SecureRandom.getInstance("SHA1PRNG").nextInt(elements.length)]; + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + public static boolean equals(@Nullable Object a, @Nullable Object b) { return a == b || (a != null && a.equals(b)); } diff --git a/src/org/thoughtcrime/securesms/util/concurrent/ListenableFuture.java b/src/org/thoughtcrime/securesms/util/concurrent/ListenableFuture.java index 59f3a54ac..b943a26fb 100644 --- a/src/org/thoughtcrime/securesms/util/concurrent/ListenableFuture.java +++ b/src/org/thoughtcrime/securesms/util/concurrent/ListenableFuture.java @@ -1,8 +1,9 @@ package org.thoughtcrime.securesms.util.concurrent; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; -public interface ListenableFuture { +public interface ListenableFuture extends Future { void addListener(Listener listener); public interface Listener { diff --git a/src/org/thoughtcrime/securesms/util/concurrent/SettableFuture.java b/src/org/thoughtcrime/securesms/util/concurrent/SettableFuture.java index 447faa981..818c4f5f1 100644 --- a/src/org/thoughtcrime/securesms/util/concurrent/SettableFuture.java +++ b/src/org/thoughtcrime/securesms/util/concurrent/SettableFuture.java @@ -7,7 +7,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -public class SettableFuture implements Future, ListenableFuture { +public class SettableFuture implements ListenableFuture { private final List> listeners = new LinkedList<>(); @@ -42,6 +42,7 @@ public class SettableFuture implements Future, ListenableFuture { this.result = result; this.completed = true; + notifyAll(); } notifyAllListeners(); @@ -54,6 +55,7 @@ public class SettableFuture implements Future, ListenableFuture { this.exception = throwable; this.completed = true; + notifyAll(); } notifyAllListeners();