diff --git a/app/build.gradle b/app/build.gradle index 4a31b20dc..bd6f4a524 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -49,6 +49,7 @@ dependencies { compile "com.squareup.retrofit2:converter-gson:2.3.0" compile "com.squareup.picasso:picasso:2.5.2" compile "com.squareup.okhttp3:okhttp:3.9.0" + compile 'com.squareup.okhttp3:logging-interceptor:3.9.0' compile "com.jakewharton.picasso:picasso2-okhttp3-downloader:1.1.0" compile "com.pkmmte.view:circularimageview:1.1" compile "com.github.varunest:sparkbutton:1.0.5" diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 1a6c0df0f..184a31f18 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -50,6 +50,7 @@ import okhttp3.Interceptor; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; +import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; @@ -126,13 +127,12 @@ public class BaseActivity extends AppCompatActivity { protected void createMastodonApi() { mastodonApiDispatcher = new Dispatcher(); - Gson gson = new GsonBuilder() - .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) + Gson gson = new GsonBuilder().registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) .registerTypeAdapter(StringWithEmoji.class, new StringWithEmojiTypeAdapter()) .create(); - OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder() - .addInterceptor(new Interceptor() { + OkHttpClient.Builder okBuilder = + OkHttpUtils.getCompatibleClientBuilder().addInterceptor(new Interceptor() { @Override public Response intercept(Chain chain) throws IOException { Request originalRequest = chain.request(); @@ -140,20 +140,21 @@ public class BaseActivity extends AppCompatActivity { Request.Builder builder = originalRequest.newBuilder(); String accessToken = getAccessToken(); if (accessToken != null) { - builder.header("Authorization", String.format("Bearer %s", - accessToken)); + builder.header("Authorization", String.format("Bearer %s", accessToken)); } Request newRequest = builder.build(); return chain.proceed(newRequest); } - }) - .dispatcher(mastodonApiDispatcher) - .build(); + }).dispatcher(mastodonApiDispatcher); - Retrofit retrofit = new Retrofit.Builder() - .baseUrl(getBaseUrl()) - .client(okHttpClient) + if (BuildConfig.DEBUG) { + okBuilder.addInterceptor( + new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)); + } + + Retrofit retrofit = new Retrofit.Builder().baseUrl(getBaseUrl()) + .client(okBuilder.build()) .addConverterFactory(GsonConverterFactory.create(gson)) .build(); @@ -161,11 +162,11 @@ public class BaseActivity extends AppCompatActivity { } protected void createTuskyApi() { - Retrofit retrofit = new Retrofit.Builder() - .baseUrl("https://" + getString(R.string.tusky_api_url)) - .client(OkHttpUtils.getCompatibleClient()) - .addConverterFactory(GsonConverterFactory.create()) - .build(); + Retrofit retrofit = + new Retrofit.Builder().baseUrl("https://" + getString(R.string.tusky_api_url)) + .client(OkHttpUtils.getCompatibleClient()) + .addConverterFactory(GsonConverterFactory.create()) + .build(); tuskyApi = retrofit.create(TuskyApi.class); } @@ -208,27 +209,25 @@ public class BaseActivity extends AppCompatActivity { long checkInterval = 1000 * 60 * minutes; AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); Intent intent = new Intent(this, PullNotificationService.class); - PendingIntent serviceAlarmIntent = PendingIntent.getService(this, SERVICE_REQUEST_CODE, - intent, PendingIntent.FLAG_UPDATE_CURRENT); - alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, - SystemClock.elapsedRealtime(), checkInterval, serviceAlarmIntent); + PendingIntent serviceAlarmIntent = PendingIntent.getService(this, SERVICE_REQUEST_CODE, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime(), + checkInterval, serviceAlarmIntent); } protected void disablePushNotifications() { // Cancel the repeating call for "pull" notifications. AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); Intent intent = new Intent(this, PullNotificationService.class); - PendingIntent serviceAlarmIntent = PendingIntent.getService(this, SERVICE_REQUEST_CODE, - intent, PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent serviceAlarmIntent = PendingIntent.getService(this, SERVICE_REQUEST_CODE, intent, + PendingIntent.FLAG_UPDATE_CURRENT); alarmManager.cancel(serviceAlarmIntent); } protected void clearNotifications() { - SharedPreferences notificationPreferences = getApplicationContext() - .getSharedPreferences("Notifications", MODE_PRIVATE); - notificationPreferences.edit() - .putString("current", "[]") - .apply(); + SharedPreferences notificationPreferences = + getApplicationContext().getSharedPreferences("Notifications", MODE_PRIVATE); + notificationPreferences.edit().putString("current", "[]").apply(); NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); manager.cancel(PullNotificationService.NOTIFY_ID); @@ -238,10 +237,10 @@ public class BaseActivity extends AppCompatActivity { long checkInterval = 1000 * 60 * minutes; AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); Intent intent = new Intent(this, PullNotificationService.class); - PendingIntent serviceAlarmIntent = PendingIntent.getService(this, SERVICE_REQUEST_CODE, - intent, PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent serviceAlarmIntent = PendingIntent.getService(this, SERVICE_REQUEST_CODE, intent, + PendingIntent.FLAG_UPDATE_CURRENT); alarmManager.cancel(serviceAlarmIntent); - alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, - SystemClock.elapsedRealtime(), checkInterval, serviceAlarmIntent); + alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime(), + checkInterval, serviceAlarmIntent); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index cc4ef7399..234316a98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -2,6 +2,11 @@ package com.keylesspalace.tusky.adapter; import android.content.Context; import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.preference.PreferenceManager; import android.support.annotation.DrawableRes; @@ -9,7 +14,9 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.content.res.AppCompatResources; import android.support.v7.widget.RecyclerView; +import android.text.SpannableStringBuilder; import android.text.Spanned; +import android.text.style.ReplacementSpan; import android.view.View; import android.widget.CompoundButton; import android.widget.ImageButton; @@ -25,11 +32,17 @@ import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.view.RoundedTransformation; import com.keylesspalace.tusky.viewdata.StatusViewData; +import com.squareup.picasso.Callback; import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; import com.varunest.sparkbutton.SparkButton; import com.varunest.sparkbutton.SparkEventListener; +import java.lang.ref.WeakReference; import java.util.Date; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; class StatusBaseViewHolder extends RecyclerView.ViewHolder { private View container; @@ -80,10 +93,8 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { videoIndicator = itemView.findViewById(R.id.status_video_indicator); mediaLabel = itemView.findViewById(R.id.status_media_label); contentWarningBar = itemView.findViewById(R.id.status_content_warning_bar); - contentWarningDescription = - itemView.findViewById(R.id.status_content_warning_description); - contentWarningButton = - itemView.findViewById(R.id.status_content_warning_button); + contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description); + contentWarningButton = itemView.findViewById(R.id.status_content_warning_button); } private void setDisplayName(String name) { @@ -97,14 +108,43 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { username.setText(usernameText); } - private void setContent(Spanned content, Status.Mention[] mentions, + private Callback spanCallback = new Callback() { + @Override + public void onSuccess() { + content.invalidate(); + } + + @Override + public void onError() { + } + }; + + private void setContent(Spanned content, Status.Mention[] mentions, List emojis, StatusActionListener listener) { + + SpannableStringBuilder builder = new SpannableStringBuilder(content); + if (!emojis.isEmpty()) { + CharSequence text = builder.subSequence(0, builder.length()); + for (Status.Emoji emoji : emojis) { + CharSequence pattern = new StringBuilder(":").append(emoji.getShortcode()).append(':'); + Matcher matcher = Pattern.compile(pattern.toString()).matcher(text); + while (matcher.find()) { + EmojiSpan span = new EmojiSpan(); + span.setCallback(spanCallback); + builder.setSpan(span, matcher.start(), matcher.end(), 0); + Picasso.with(container.getContext()) + .load(emoji.getUrl()) + .into(span); + } + } + } + /* Redirect URLSpan's in the status content to the listener for viewing tag pages and * account pages. */ Context context = this.content.getContext(); - boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean("customTabs", true); - LinkHelper.setClickableText(this.content, content, mentions, useCustomTabs, listener); + boolean useCustomTabs = + PreferenceManager.getDefaultSharedPreferences(context).getBoolean("customTabs", true); + LinkHelper.setClickableText(this.content, builder, mentions, useCustomTabs, listener); } void setAvatar(String url, @Nullable String rebloggedUrl) { @@ -177,15 +217,13 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { private void setMediaPreviews(final Status.MediaAttachment[] attachments, boolean sensitive, final StatusActionListener listener, boolean showingSensitive) { final ImageView[] previews = { - mediaPreview0, - mediaPreview1, - mediaPreview2, - mediaPreview3 + mediaPreview0, mediaPreview1, mediaPreview2, mediaPreview3 }; Context context = mediaPreview0.getContext(); - int mediaPreviewUnloadedId = ThemeUtils.getDrawableId(itemView.getContext(), - R.attr.media_preview_unloaded_drawable, android.R.color.black); + int mediaPreviewUnloadedId = + ThemeUtils.getDrawableId(itemView.getContext(), R.attr.media_preview_unloaded_drawable, + android.R.color.black); final int n = Math.min(attachments.length, Status.MAX_MEDIA_ATTACHMENTS); @@ -200,9 +238,7 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { previews[i].setVisibility(View.VISIBLE); if (previewUrl == null || previewUrl.isEmpty()) { - Picasso.with(context) - .load(mediaPreviewUnloadedId) - .into(previews[i]); + Picasso.with(context).load(mediaPreviewUnloadedId).into(previews[i]); } else { Picasso.with(context) .load(previewUrl) @@ -211,8 +247,7 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { } final Status.MediaAttachment.Type type = attachments[i].type; - if (type == Status.MediaAttachment.Type.VIDEO - | type == Status.MediaAttachment.Type.GIFV) { + if (type == Status.MediaAttachment.Type.VIDEO | type == Status.MediaAttachment.Type.GIFV) { videoIndicator.setVisibility(View.VISIBLE); } @@ -233,7 +268,7 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (sensitive && (!isAlwayShowSensitive)) { sensitiveMediaWarning.setVisibility(showingSensitive ? View.GONE : View.VISIBLE); sensitiveMediaShow.setVisibility(showingSensitive ? View.VISIBLE : View.GONE); - sensitiveMediaShow.setOnClickListener(new View.OnClickListener(){ + sensitiveMediaShow.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { v.setVisibility(View.GONE); @@ -330,20 +365,19 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { contentWarningDescription.setText(spoilerText); contentWarningBar.setVisibility(View.VISIBLE); contentWarningButton.setChecked(expanded); - contentWarningButton.setOnCheckedChangeListener( - new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - if (getAdapterPosition() != RecyclerView.NO_POSITION) { - listener.onExpandedChange(isChecked, getAdapterPosition()); - } - if (isChecked) { - content.setVisibility(View.VISIBLE); - } else { - content.setVisibility(View.GONE); - } - } - }); + contentWarningButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + listener.onExpandedChange(isChecked, getAdapterPosition()); + } + if (isChecked) { + content.setVisibility(View.VISIBLE); + } else { + content.setVisibility(View.GONE); + } + } + }); if (expanded) { content.setVisibility(View.VISIBLE); } else { @@ -441,7 +475,7 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { setDisplayName(status.getUserFullName()); setUsername(status.getNickname()); setCreatedAt(status.getCreatedAt()); - setContent(status.getContent(), status.getMentions(), listener); + setContent(status.getContent(), status.getMentions(), status.getEmojis(), listener); setAvatar(status.getAvatar(), status.getRebloggedAvatar()); setReblogged(status.isReblogged()); setFavourited(status.isFavourited()); @@ -478,4 +512,58 @@ class StatusBaseViewHolder extends RecyclerView.ViewHolder { setSpoilerText(status.getSpoilerText(), status.isExpanded(), listener); } } + + static class EmojiSpan extends ReplacementSpan implements Target { + + private @Nullable + Drawable imageDrawable; + private WeakReference callbackWeakReference; + + public void setImageDrawable(@Nullable Drawable imageDrawable) { + this.imageDrawable = imageDrawable; + } + + public void setCallback(Callback callback) { + this.callbackWeakReference = new WeakReference(callback); + } + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, + @Nullable Paint.FontMetricsInt fm) { + if (imageDrawable == null) return 0; + Rect sizeRect = imageDrawable.getBounds(); + return sizeRect.right; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, + int top, int y, int bottom, @NonNull Paint paint) { + if (imageDrawable == null) return; + canvas.save(); + int transY = bottom - imageDrawable.getBounds().bottom; + transY -= paint.getFontMetricsInt().descent; + canvas.translate(x, transY); + imageDrawable.draw(canvas); + canvas.restore(); + } + + @Override + public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) { + imageDrawable = new BitmapDrawable(bitmap); + imageDrawable.setBounds(0, 0, imageDrawable.getIntrinsicWidth() + 10, + imageDrawable.getIntrinsicHeight() + 10); + if (callbackWeakReference != null) { + Callback cb = callbackWeakReference.get(); + if (cb != null) cb.onSuccess(); + } + } + + @Override + public void onBitmapFailed(Drawable errorDrawable) { + } + + @Override + public void onPrepareLoad(Drawable placeHolderDrawable) { + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.java b/app/src/main/java/com/keylesspalace/tusky/entity/Status.java index e9bb9ff15..7d28ea9da 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.java +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.java @@ -24,6 +24,7 @@ import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; import java.util.Date; +import java.util.List; public class Status { private Status actionableStatus; @@ -79,6 +80,8 @@ public class Status { public boolean sensitive; + public List emojis; + @SerializedName("spoiler_text") public String spoilerText; @@ -179,4 +182,17 @@ public class Status { public String name; public String website; } + + public static class Emoji { + private String shortcode; + private String url; + + public String getShortcode() { + return shortcode; + } + + public String getUrl() { + return url; + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java index 22753b8c9..d344598b4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java @@ -20,8 +20,7 @@ public final class ViewDataUtils { public static StatusViewData statusToViewData(@Nullable Status status) { if (status == null) return null; Status visibleStatus = status.reblog == null ? status : status.reblog; - return new StatusViewData.Builder() - .setId(status.id) + return new StatusViewData.Builder().setId(status.id) .setAttachments(visibleStatus.attachments) .setAvatar(visibleStatus.account.avatar) .setContent(visibleStatus.content) @@ -44,6 +43,7 @@ public final class ViewDataUtils { .setSenderId(visibleStatus.account.id) .setRebloggingEnabled(visibleStatus.rebloggingAllowed()) .setApplication(visibleStatus.application) + .setEmojis(visibleStatus.emojis) .createStatusViewData(); } @@ -64,8 +64,8 @@ public final class ViewDataUtils { statusToViewData(notification.status)); } - public static List - notificationListToViewDataList(List notifications) { + public static List notificationListToViewDataList( + List notifications) { List viewDatas = new ArrayList<>(notifications.size()); for (Notification n : notifications) { viewDatas.add(notificationToViewData(n)); diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index 5def90bc3..32d526e69 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -5,7 +5,9 @@ import android.text.Spanned; import com.keylesspalace.tusky.entity.Status; +import java.util.Collections; import java.util.Date; +import java.util.List; /** * Created by charlag on 11/07/2017. @@ -41,16 +43,15 @@ public final class StatusViewData { private final String senderId; private final boolean rebloggingEnabled; private final Status.Application application; + private final List emojis; public StatusViewData(String id, Spanned content, boolean reblogged, boolean favourited, - String spoilerText, Status.Visibility visibility, - Status.MediaAttachment[] attachments, String rebloggedByUsername, - String rebloggedAvatar, boolean sensitive, boolean isExpanded, - boolean isShowingSensitiveWarning, String userFullName, String nickname, - String avatar, Date createdAt, String reblogsCount, - String favouritesCount, String inReplyToId, Status.Mention[] mentions, - String senderId, boolean rebloggingEnabled, - Status.Application application) { + String spoilerText, Status.Visibility visibility, Status.MediaAttachment[] attachments, + String rebloggedByUsername, String rebloggedAvatar, boolean sensitive, boolean isExpanded, + boolean isShowingSensitiveWarning, String userFullName, String nickname, String avatar, + Date createdAt, String reblogsCount, String favouritesCount, String inReplyToId, + Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, + Status.Application application, List emojis) { this.id = id; this.content = content; this.reblogged = reblogged; @@ -74,6 +75,7 @@ public final class StatusViewData { this.senderId = senderId; this.rebloggingEnabled = rebloggingEnabled; this.application = application; + this.emojis = emojis; } public String getId() { @@ -173,6 +175,10 @@ public final class StatusViewData { return application; } + public List getEmojis() { + return emojis; + } + public static class Builder { private String id; private Spanned content; @@ -197,6 +203,7 @@ public final class StatusViewData { private String senderId; private boolean rebloggingEnabled; private Status.Application application; + private List emojis; public Builder() { } @@ -225,6 +232,7 @@ public final class StatusViewData { senderId = viewData.senderId; rebloggingEnabled = viewData.rebloggingEnabled; application = viewData.application; + emojis = viewData.getEmojis(); } public Builder setId(String id) { @@ -342,12 +350,17 @@ public final class StatusViewData { return this; } + public Builder setEmojis(List emojis) { + this.emojis = emojis; + return this; + } + public StatusViewData createStatusViewData() { + if (this.emojis == null) emojis = Collections.emptyList(); return new StatusViewData(id, content, reblogged, favourited, spoilerText, visibility, attachments, rebloggedByUsername, rebloggedAvatar, isSensitive, isExpanded, - isShowingSensitiveContent, userFullName, nickname, avatar, createdAt, - reblogsCount, favouritesCount, inReplyToId, mentions, senderId, - rebloggingEnabled, application); + isShowingSensitiveContent, userFullName, nickname, avatar, createdAt, reblogsCount, + favouritesCount, inReplyToId, mentions, senderId, rebloggingEnabled, application, emojis); } } }