package com.vaguehope.onosendai.provider.twitter;
import java.io.IOException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.TimeUnit;
import twitter4j.HashtagEntity;
import twitter4j.MediaEntity;
import twitter4j.MediaEntity.Variant;
import twitter4j.Paging;
import twitter4j.ResponseList;
import twitter4j.Status;
import twitter4j.Twitter;
import twitter4j.TwitterException;
import twitter4j.URLEntity;
import twitter4j.User;
import twitter4j.UserMentionEntity;
import com.vaguehope.onosendai.C;
import com.vaguehope.onosendai.config.Account;
import com.vaguehope.onosendai.model.Meta;
import com.vaguehope.onosendai.model.MetaType;
import com.vaguehope.onosendai.model.MetaUtils;
import com.vaguehope.onosendai.model.Tweet;
import com.vaguehope.onosendai.model.TweetList;
import com.vaguehope.onosendai.util.ArrayHelper;
import com.vaguehope.onosendai.util.DateHelper;
import com.vaguehope.onosendai.util.ExcpetionHelper;
import com.vaguehope.onosendai.util.ImageHostHelper;
import com.vaguehope.onosendai.util.IoHelper;
import com.vaguehope.onosendai.util.LogWrapper;
import com.vaguehope.onosendai.util.StringHelper;
public final class TwitterUtils {
private static final int TWITTER_ERROR_CODE_ACCOUNT_SUSPENDED = 64;
private static final int TWITTER_ERROR_CODE_RATE_LIMIT_EXCEEDED = 88;
private static final int TWITTER_ERROR_CODE_INVALID_EXPIRED_TOKEN = 89;
private static final int TWITTER_ERROR_CODE_TWITTER_IS_DOWN = 131;
private static final int TWITTER_ERROR_CODE_OVER_CAPCACITY = 130;
private static final int TWITTER_ERROR_CODE_NOT_AUTH_TO_VIEW_STATUS = 179;
private static final int TWITTER_ERROR_CODE_DAILY_STATUS_LIMIT_EXCEEDED = 185;
private static final LogWrapper LOG = new LogWrapper("TU");
private TwitterUtils () {
throw new AssertionError();
}
/*
* Paging: https://dev.twitter.com/docs/working-with-timelines
* http://twitter4j.org/en/code-examples.html
*/
static TweetList fetchTwitterFeed (final Account account, final Twitter t, final FeedGetter getter, final long sinceId, final boolean hdMedia, final Collection<Meta> extraMetas) throws TwitterException {
final List<Tweet> tweets = new ArrayList<Tweet>();
final List<Tweet> quotedTweets = new ArrayList<Tweet>();
final int minCount = getter.recommendedFetchCount();
final int pageSize = Math.min(minCount, C.TWEET_FETCH_PAGE_SIZE);
int page = 1; // First page is 1.
long minId = -1;
while (tweets.size() < minCount) {
final Paging paging = new Paging(page, pageSize);
if (sinceId > 0) paging.setSinceId(sinceId);
if (minId > 0) paging.setMaxId(minId);
final ResponseList<Status> timelinePage = getter.getTweets(t, paging);
LOG.i("Page %d of '%s'(sinceId=%s) contains %d items.", page, getter.toString(), sinceId, timelinePage.size());
if (timelinePage.size() < 1) break;
addTweetsToList(tweets, account, timelinePage, t.getId(), hdMedia, extraMetas, quotedTweets);
minId = TwitterUtils.minIdOf(minId, timelinePage);
page++;
}
return new TweetList(tweets, quotedTweets);
}
static void addTweetsToList (final List<Tweet> list, final Account account, final List<Status> tweets, final long ownId, final boolean hdMedia, final Collection<Meta> extraMetas, final List<Tweet> quotedTweets) {
for (final Status status : tweets) {
try {
list.add(convertTweet(account, status, ownId, hdMedia, extraMetas, quotedTweets));
}
catch (final RuntimeException e) { // Better chance of debugging.
throw new RuntimeException("Failed to convert status: " + status, e);
}
}
}
static Tweet convertTweet (final Account account, final Status status, final long ownId, final boolean hdMedia) {
return convertTweet(account, status, ownId, hdMedia, null, null);
}
static Tweet convertTweet (final Account account, final Status status, final long ownId, final boolean hdMedia, final Collection<Meta> extraMetas, final List<Tweet> quotedTweets) {
final User statusUser = status.getUser();
final long statusUserId = statusUser != null ? statusUser.getId() : -1;
// The order things are added to these lists is important.
final List<Meta> metas = new ArrayList<Meta>();
final List<String> userSubtitle = new ArrayList<String>();
metas.add(new Meta(MetaType.ACCOUNT, account.getId()));
if (extraMetas != null) metas.addAll(extraMetas);
final User viaUser;
if (status.isRetweet()) {
viaUser = statusUser;
metas.add(new Meta(MetaType.POST_TIME, String.valueOf(TimeUnit.MILLISECONDS.toSeconds(status.getRetweetedStatus().getCreatedAt().getTime()))));
}
else {
viaUser = null;
}
final Status s = status.isRetweet() ? status.getRetweetedStatus() : status;
if (s.getUser() == null) throw new IllegalStateException("Status has null user: " + s);
addMedia(s, metas, hdMedia, userSubtitle);
checkUrlsForMedia(s, metas, hdMedia);
final String text = removeMediaUrls(expandUrls(s.getText(), s.getURLEntities(), metas), s);
addHashtags(s, metas);
addMentions(s, metas, statusUserId, ownId);
if (viaUser != null && viaUser.getId() != ownId) metas.add(new Meta(MetaType.MENTION, viaUser.getScreenName(), viaUser.getName()));
if (statusUserId == ownId) metas.add(new Meta(MetaType.EDIT_SID, status.getId()));
if (s.getInReplyToStatusId() > 0) {
metas.add(new Meta(MetaType.INREPLYTO, String.valueOf(s.getInReplyToStatusId())));
}
else if (s.isRetweet() && s.getRetweetedStatus().getId() > 0) { // FIXME should be status no s?
metas.add(new Meta(MetaType.INREPLYTO, String.valueOf(s.getRetweetedStatus().getId())));
}
final Status q = s.getQuotedStatus();
if (q != null && quotedTweets != null) {
metas.add(new Meta(MetaType.QUOTED_SID, q.getId()));
if (q.getUser() != null) { // Sometimes Twitter does this. I have no idea why.
quotedTweets.add(convertTweet(account, q, ownId, hdMedia, extraMetas, quotedTweets));
}
else {
// Its not useful, so let it get loaded later.
LOG.w("Inline quoted status has null user: " + q);
}
}
final int mediaCount = MetaUtils.countMetaOfType(metas, MetaType.MEDIA);
if (mediaCount > 1) userSubtitle.add(String.format("%s pictures", mediaCount)); //ES
if (viaUser != null) userSubtitle.add(String.format("via %s", viaUser.getScreenName())); //ES
final String fullSubtitle = viaUser != null ? String.format("via %s", viaUser.getName()) : null; //ES
// https://dev.twitter.com/docs/user-profile-images-and-banners
return new Tweet(String.valueOf(s.getId()),
s.getUser().getScreenName(), s.getUser().getName(),
userSubtitle.size() > 0 ? ArrayHelper.join(userSubtitle, ", ") : null,
fullSubtitle,
text,
TimeUnit.MILLISECONDS.toSeconds(status.getCreatedAt().getTime()),
hdMedia ? s.getUser().getBiggerProfileImageURLHttps() : s.getUser().getProfileImageURLHttps(),
MetaUtils.firstMetaOfTypesData(metas, MetaType.MEDIA),
q != null ? String.valueOf(q.getId()) : null,
metas);
}
static long minIdOf (final long statingMin, final List<Status> tweets) {
long min = statingMin;
for (final Status status : tweets) {
min = Math.min(min, status.getId());
}
return min;
}
private static String expandUrls (final String text, final URLEntity[] urls, final List<Meta> metas) {
if (urls == null || urls.length < 1) return text;
String textWithUrls = text;
for (int i = 0; i < urls.length; i++) {
final URLEntity url = urls[i];
if (url.getURL() != null && url.getExpandedURL() != null) {
textWithUrls = StringHelper.replaceOnce(textWithUrls, url.getURL(), url.getExpandedURL());
}
final String fullUrl = url.getExpandedURL() != null ? url.getExpandedURL() : url.getURL();
if (!(url instanceof MediaEntity) && !MetaUtils.containsMetaWithTitle(metas, fullUrl)) { // Image metas have same title.
metas.add(new Meta(MetaType.URL, fullUrl, url.getDisplayURL()));
}
}
//LOG.d("Expanded '%s' --> '%s'.", text, expandedText);
return textWithUrls;
}
private static void addMedia (final Status s, final List<Meta> metas, final boolean hdMedia, final List<String> userSubtitle) {
final MediaEntity[] mes = s.getMediaEntities();
if (mes == null) return;
boolean hasGif = false;
boolean hasVideo = false;
for (final MediaEntity me : mes) {
final String clickUrl = me.getExpandedURL() != null ? me.getExpandedURL() : me.getURL();
String imgUrl = me.getMediaURLHttps();
if (hdMedia) imgUrl += ":large";
metas.add(new Meta(MetaType.MEDIA, imgUrl, clickUrl));
if (StringHelper.notEmpty(me.getExtAltText())) {
metas.add(new Meta(MetaType.ALT_TEXT, me.getExtAltText()));
}
final Variant[] variants = me.getVideoVariants();
if (variants != null) {
Arrays.sort(variants, VariantOrder.INSTANCE);
for (final Variant variant : variants) {
if ("animated_gif".equals(me.getType())) hasGif = true;
else if ("video".equals(me.getType())) hasVideo = true;
final StringBuilder title = new StringBuilder();
title.append(variant.getContentType());
if (me.getVideoDurationMillis() > 0) title.append(" ").append(DateHelper.formatDurationMillis(me.getVideoDurationMillis()));
if (variant.getBitrate() > 0) title.append(" ").append(IoHelper.readableFileSize(variant.getBitrate())).append("/s");
metas.add(new Meta(MetaType.URL, variant.getUrl(), title.toString()));
}
}
}
if (hasGif) userSubtitle.add("gif"); //ES
if (hasVideo) userSubtitle.add("video"); //ES
}
private static String removeMediaUrls(final String text, final Status s) {
final MediaEntity[] mes = s.getMediaEntities();
if (mes == null || mes.length < 1) return text;
String textWithoutMedia = text;
for (final MediaEntity me : mes) {
textWithoutMedia = StringHelper.replaceOnce(textWithoutMedia, me.getURL(), "");
}
return textWithoutMedia;
}
private static void checkUrlsForMedia (final Status s, final List<Meta> metas, final boolean hdMedia) {
final URLEntity[] urls = s.getURLEntities();
if (urls == null) return;
for (final URLEntity url : urls) {
final String fullUrl = url.getExpandedURL() != null ? url.getExpandedURL() : url.getURL();
final List<String> thumbUrls = ImageHostHelper.thumbUrl(fullUrl, hdMedia);
if (thumbUrls != null) {
for (final String thumbUrl : thumbUrls) {
metas.add(new Meta(MetaType.MEDIA, thumbUrl, fullUrl));
}
}
}
}
private static void addHashtags (final Status s, final List<Meta> metas) {
final HashtagEntity[] tags = s.getHashtagEntities();
if (tags == null) return;
for (final HashtagEntity tag : tags) {
metas.add(new Meta(MetaType.HASHTAG, tag.getText()));
}
}
private static void addMentions (final Status s, final List<Meta> metas, final long tweetOwnderId, final long tweetViewerId) {
final UserMentionEntity[] umes = s.getUserMentionEntities();
if (umes == null) return;
for (final UserMentionEntity ume : umes) {
if (ume == null) throw new IllegalStateException("null entry in UME array: " + Arrays.toString(umes));
if (ume.getId() == tweetOwnderId) continue;
if (ume.getId() == tweetViewerId) continue;
metas.add(new Meta(MetaType.MENTION, ume.getScreenName(), ume.getName()));
}
}
public static String friendlyExceptionMessage (final TwitterException e) {
switch (e.getErrorCode()) {
case TWITTER_ERROR_CODE_ACCOUNT_SUSPENDED:
return "Your account is suspended and is not permitted to access this feature. :("; //ES
case TWITTER_ERROR_CODE_RATE_LIMIT_EXCEEDED:
return "Rate limit exceeded. Please try again in a while."; //ES
case TWITTER_ERROR_CODE_INVALID_EXPIRED_TOKEN:
return "Invalid or expired token. Please try reauthenticating."; //ES
case TWITTER_ERROR_CODE_OVER_CAPCACITY:
return "OMG Twitter is over capacity!"; //ES
case TWITTER_ERROR_CODE_TWITTER_IS_DOWN:
return "OMG Twitter is down!"; //ES
case TWITTER_ERROR_CODE_NOT_AUTH_TO_VIEW_STATUS:
return "You are not authorized to see this status."; //ES
case TWITTER_ERROR_CODE_DAILY_STATUS_LIMIT_EXCEEDED:
return "You are over daily status update limit."; //ES
default:
}
final Throwable cause = e.getCause();
if (cause != null) {
if (cause instanceof UnknownHostException) {
return "Network error: " + cause.getMessage(); //ES
}
else if (cause instanceof IOException && StringHelper.safeContainsIgnoreCase(cause.getMessage(), "connection timed out")) {
return "Network error: Connection timed out."; //ES
}
else if (cause instanceof IOException) {
return "Network error: " + cause; //ES
}
else if (cause instanceof twitter4j.JSONException) {
return "Network error: Invalid or incomplete data received."; //ES
}
}
return ExcpetionHelper.causeTrace(e);
}
}