/*
* Copyright (C) 2013 yvolk (Yuri Volkov), http://yurivolkov.com
*
* 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.andstatus.app.net.social.pumpio;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import org.andstatus.app.context.MyContextHolder;
import org.andstatus.app.data.DownloadStatus;
import org.andstatus.app.data.MyContentType;
import org.andstatus.app.net.http.ConnectionException;
import org.andstatus.app.net.http.ConnectionException.StatusCode;
import org.andstatus.app.net.http.HttpConnection;
import org.andstatus.app.net.http.HttpConnectionData;
import org.andstatus.app.net.social.Connection;
import org.andstatus.app.net.social.MbAttachment;
import org.andstatus.app.net.social.MbMessage;
import org.andstatus.app.net.social.MbRateLimitStatus;
import org.andstatus.app.net.social.MbTimelineItem;
import org.andstatus.app.net.social.MbUser;
import org.andstatus.app.net.social.TimelinePosition;
import org.andstatus.app.origin.OriginConnectionData;
import org.andstatus.app.util.JsonUtils;
import org.andstatus.app.util.MyHtml;
import org.andstatus.app.util.MyLog;
import org.andstatus.app.util.TriState;
import org.andstatus.app.util.UrlUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
/**
* Implementation of pump.io API: <a href="https://github.com/e14n/pump.io/blob/master/API.md">https://github.com/e14n/pump.io/blob/master/API.md</a>
* @author yvolk@yurivolkov.com
*/
public class ConnectionPumpio extends Connection {
private static final String TAG = ConnectionPumpio.class.getSimpleName();
static final String APPLICATION_ID = "http://andstatus.org/andstatus";
@Override
public void enrichConnectionData(OriginConnectionData connectionData) {
super.enrichConnectionData(connectionData);
if (!TextUtils.isEmpty(connectionData.getAccountName().getUsername())) {
connectionData.setOriginUrl(UrlUtils.buildUrl(usernameToHost(
connectionData.getAccountName().getUsername()), connectionData.isSsl()));
}
}
@Override
protected String getApiPath1(ApiRoutineEnum routine) {
String url;
switch(routine) {
case ACCOUNT_VERIFY_CREDENTIALS:
url = "whoami";
break;
case GET_FOLLOWERS:
case GET_FOLLOWERS_IDS:
url = "user/%nickname%/followers";
break;
case GET_FRIENDS:
case GET_FRIENDS_IDS:
url = "user/%nickname%/following";
break;
case GET_USER:
url = "user/%nickname%/profile";
break;
case REGISTER_CLIENT:
url = "client/register";
break;
case HOME_TIMELINE:
url = "user/%nickname%/inbox";
break;
case POST_WITH_MEDIA:
url = "user/%nickname%/uploads";
break;
case CREATE_FAVORITE:
case DESTROY_FAVORITE:
case FOLLOW_USER:
case POST_DIRECT_MESSAGE:
case POST_REBLOG:
case DESTROY_MESSAGE:
case POST_MESSAGE:
case USER_TIMELINE:
url = "user/%nickname%/feed";
break;
default:
url = "";
break;
}
return prependWithBasicPath(url);
}
@Override
public MbRateLimitStatus rateLimitStatus() throws ConnectionException {
// TODO Method stub
return new MbRateLimitStatus();
}
@Override
public MbUser verifyCredentials() throws ConnectionException {
JSONObject user = http.getRequest(getApiPath(ApiRoutineEnum.ACCOUNT_VERIFY_CREDENTIALS));
return userFromJson(user);
}
private MbUser userFromJson(JSONObject jso) throws ConnectionException {
if (!ObjectType.PERSON.isMyType(jso)) {
return MbUser.EMPTY;
}
String oid = jso.optString("id");
MbUser user = MbUser.fromOriginAndUserOid(data.getOriginId(), oid);
user.actor = MbUser.fromOriginAndUserOid(data.getOriginId(), data.getAccountUserOid());
user.setUserName(userOidToUsername(oid));
user.setRealName(jso.optString("displayName"));
user.avatarUrl = JsonUtils.optStringInside(jso, "image", "url");
user.location = JsonUtils.optStringInside(jso, "location", "displayName");
user.setDescription(jso.optString("summary"));
user.setHomepage(jso.optString("url"));
user.setProfileUrl(jso.optString("url"));
user.setUpdatedDate(dateFromJson(jso, "updated"));
return user;
}
@Override
public long parseDate(String stringDate) {
return parseIso8601Date(stringDate);
}
@Override
public MbMessage destroyFavorite(String messageId) throws ConnectionException {
return actOnMessage(ActivityType.UNFAVORITE, messageId);
}
@Override
public MbMessage createFavorite(String messageId) throws ConnectionException {
return actOnMessage(ActivityType.FAVORITE, messageId);
}
@Override
public boolean destroyStatus(String messageId) throws ConnectionException {
MbMessage message = actOnMessage(ActivityType.DELETE, messageId);
return !message.isEmpty();
}
private MbMessage actOnMessage(ActivityType activityType, String messageId) throws ConnectionException {
return ActivitySender.fromId(this, messageId).sendMessage(activityType);
}
@Override
public List<MbUser> getFollowers(String userId) throws ConnectionException {
return getUsers(userId, ApiRoutineEnum.GET_FOLLOWERS);
}
@Override
public List<MbUser> getFriends(String userId) throws ConnectionException {
return getUsers(userId, ApiRoutineEnum.GET_FRIENDS);
}
@NonNull
private List<MbUser> getUsers(String userId, ApiRoutineEnum apiRoutine) throws ConnectionException {
int limit = 200;
ConnectionAndUrl conu = getConnectionAndUrl(apiRoutine, userId);
Uri sUri = Uri.parse(conu.url);
Uri.Builder builder = sUri.buildUpon();
builder.appendQueryParameter("count",String.valueOf(fixedDownloadLimitForApiRoutine(limit, apiRoutine)));
String url = builder.build().toString();
JSONArray jArr = conu.httpConnection.getRequestAsArray(url);
List<MbUser> users = new ArrayList<>();
if (jArr != null) {
for (int index = 0; index < jArr.length(); index++) {
try {
JSONObject jso = jArr.getJSONObject(index);
MbUser item = userFromJson(jso);
users.add(item);
} catch (JSONException e) {
throw ConnectionException.loggedJsonException(this, "Parsing list of users", e, null);
}
}
}
MyLog.d(TAG, apiRoutine + " '" + url + "' " + users.size() + " users");
return users;
}
@Override
protected MbMessage getMessage1(String messageId) throws ConnectionException {
JSONObject message = http.getRequest(messageId);
return messageFromJson(message);
}
@Override
public MbMessage updateStatus(String messageIn, String statusId, String inReplyToId, Uri mediaUri) throws ConnectionException {
String message = toHtmlIfAllowed(messageIn);
ActivitySender sender = ActivitySender.fromContent(this, statusId, message);
sender.setInReplyTo(inReplyToId);
sender.setMediaUri(mediaUri);
return messageFromJson(sender.sendMe(ActivityType.POST));
}
private String toHtmlIfAllowed(String message) {
return MyContextHolder.get().persistentOrigins().isHtmlContentAllowed(data.getOriginId()) ?
MyHtml.htmlify(message) : message;
}
String oidToObjectType(String oid) {
String objectType = "";
if (oid.contains("/comment/")) {
objectType = "comment";
} else if (oid.startsWith("acct:")) {
objectType = "person";
} else if (oid.contains("/note/")) {
objectType = "note";
} else if (oid.contains("/notice/")) {
objectType = "note";
} else if (oid.contains("/person/")) {
objectType = "person";
} else if (oid.contains("/collection/") || oid.endsWith("/followers")) {
objectType = "collection";
} else if (oid.contains("/user/")) {
objectType = "person";
} else {
String pattern = "/api/";
int indStart = oid.indexOf(pattern);
if (indStart >= 0) {
int indEnd = oid.indexOf("/", indStart+pattern.length());
if (indEnd > indStart) {
objectType = oid.substring(indStart+pattern.length(), indEnd);
}
}
}
if (TextUtils.isEmpty(objectType)) {
objectType = "unknown object type: " + oid;
MyLog.e(this, objectType);
}
return objectType;
}
ConnectionAndUrl getConnectionAndUrl(ApiRoutineEnum apiRoutine, String userId) throws ConnectionException {
if (TextUtils.isEmpty(userId)) {
throw new ConnectionException(StatusCode.BAD_REQUEST, apiRoutine + ": userId is required");
}
return getConnectionAndUrlForUsername(apiRoutine, userOidToUsername(userId));
}
private ConnectionAndUrl getConnectionAndUrlForUsername(ApiRoutineEnum apiRoutine, String username) throws ConnectionException {
ConnectionAndUrl conu = new ConnectionAndUrl();
conu.url = this.getApiPath(apiRoutine);
if (TextUtils.isEmpty(conu.url)) {
throw new ConnectionException(StatusCode.UNSUPPORTED_API, "The API is not supported yet: " + apiRoutine);
}
if (TextUtils.isEmpty(username)) {
throw new ConnectionException(StatusCode.BAD_REQUEST, apiRoutine + ": userName is required");
}
String nickname = usernameToNickname(username);
if (TextUtils.isEmpty(nickname)) {
throw new ConnectionException(StatusCode.BAD_REQUEST, apiRoutine + ": wrong userName='" + username + "'");
}
String host = usernameToHost(username);
conu.httpConnection = http;
if (TextUtils.isEmpty(host)) {
throw new ConnectionException(StatusCode.BAD_REQUEST, apiRoutine + ": host is empty for the userName='" + username + "'");
} else if (http.data.originUrl == null || host.compareToIgnoreCase(http.data.originUrl.getHost()) != 0) {
MyLog.v(this, "Requesting data from the host: " + host);
HttpConnectionData connectionData1 = http.data.copy();
connectionData1.oauthClientKeys = null;
connectionData1.originUrl = UrlUtils.buildUrl(host, connectionData1.isSsl());
conu.httpConnection = http.getNewInstance();
conu.httpConnection.setConnectionData(connectionData1);
}
if (!conu.httpConnection.data.areOAuthClientKeysPresent()) {
conu.httpConnection.registerClient(getApiPath(ApiRoutineEnum.REGISTER_CLIENT));
if (!conu.httpConnection.getCredentialsPresent()) {
throw ConnectionException.fromStatusCodeAndHost(StatusCode.NO_CREDENTIALS_FOR_HOST, "No credentials", conu.httpConnection.data.originUrl);
}
}
conu.url = conu.url.replace("%nickname%", nickname);
return conu;
}
static class ConnectionAndUrl {
String url;
HttpConnection httpConnection;
}
@Override
public MbMessage postDirectMessage(String messageIn, String statusId, String recipientId, Uri mediaUri) throws ConnectionException {
String message = toHtmlIfAllowed(messageIn);
ActivitySender sender = ActivitySender.fromContent(this, statusId, message);
sender.setRecipient(recipientId);
sender.setMediaUri(mediaUri);
return messageFromJson(sender.sendMe(ActivityType.POST));
}
@Override
public MbMessage postReblog(String rebloggedId) throws ConnectionException {
return actOnMessage(ActivityType.SHARE, rebloggedId);
}
@Override
public List<MbTimelineItem> getTimeline(ApiRoutineEnum apiRoutine, TimelinePosition youngestPosition,
TimelinePosition oldestPosition, int limit, String userId)
throws ConnectionException {
ConnectionAndUrl conu = getConnectionAndUrl(apiRoutine, userId);
Uri sUri = Uri.parse(conu.url);
Uri.Builder builder = sUri.buildUpon();
if (youngestPosition.isPresent()) {
// The "since" should point to the "Activity" on the timeline, not to the message
// Otherwise we will always get "not found"
builder.appendQueryParameter("since", youngestPosition.getPosition());
} else if (oldestPosition.isPresent()) {
builder.appendQueryParameter("before", oldestPosition.getPosition());
}
builder.appendQueryParameter("count",String.valueOf(fixedDownloadLimitForApiRoutine(limit, apiRoutine)));
String url = builder.build().toString();
JSONArray jArr = conu.httpConnection.getRequestAsArray(url);
List<MbTimelineItem> timeline = new ArrayList<>();
if (jArr != null) {
// Read the activities in chronological order
for (int index = jArr.length() - 1; index >= 0; index--) {
try {
JSONObject jso = jArr.getJSONObject(index);
MbTimelineItem item = timelineItemFromJson(jso);
timeline.add(item);
} catch (JSONException e) {
throw ConnectionException.loggedJsonException(this, "Parsing timeline", e, null);
}
}
}
MyLog.d(TAG, "getTimeline '" + url + "' " + timeline.size() + " messages");
return timeline;
}
@Override
public int fixedDownloadLimitForApiRoutine(int limit, ApiRoutineEnum apiRoutine) {
final int maxLimit = apiRoutine == ApiRoutineEnum.GET_FRIENDS ? 200 : 20;
int out = super.fixedDownloadLimitForApiRoutine(limit, apiRoutine);
if (out > maxLimit) {
out = maxLimit;
}
return out;
}
private MbTimelineItem timelineItemFromJson(JSONObject activity) throws ConnectionException {
MbTimelineItem item = new MbTimelineItem();
if (ObjectType.ACTIVITY.isMyType(activity)) {
try {
item.timelineItemPosition = new TimelinePosition(activity.optString("id"));
item.timelineItemDate = dateFromJson(activity, "updated");
if (ObjectType.PERSON.isMyType(activity.getJSONObject("object"))) {
item.mbUser = userFromJsonActivity(activity);
} else {
item.mbMessage = messageFromJsonActivity(activity);
}
} catch (JSONException e) {
throw ConnectionException.loggedJsonException(this, "Parsing timeline item", e, activity);
}
} else {
MyLog.e(this, "Not an Activity in the timeline:" + activity.toString() );
item.mbMessage = messageFromJson(activity);
}
return item;
}
MbUser userFromJsonActivity(JSONObject activity) throws ConnectionException {
MbUser mbUser;
try {
ActivityType activityType = ActivityType.load(activity.getString("verb"));
String oid = activity.optString("id");
if (TextUtils.isEmpty(oid)) {
MyLog.d(TAG, "Pumpio activity has no id:" + activity.toString(2));
return MbUser.EMPTY;
}
mbUser = userFromJson(activity.getJSONObject("object"));
if (activity.has("actor")) {
mbUser.actor = userFromJson(activity.getJSONObject("actor"));
}
if (activityType.equals(ActivityType.FOLLOW)) {
mbUser.followedByActor = TriState.TRUE;
} else if (activityType.equals(ActivityType.STOP_FOLLOWING)) {
mbUser.followedByActor = TriState.FALSE;
}
} catch (JSONException e) {
throw ConnectionException.loggedJsonException(this, "Parsing activity", e, activity);
}
return mbUser;
}
MbMessage messageFromJson(JSONObject jso) throws ConnectionException {
if (MyLog.isVerboseEnabled()) {
try {
MyLog.v(this, "messageFromJson: " + jso.toString(2));
} catch (NullPointerException | JSONException e) {
throw ConnectionException.loggedJsonException(this, "messageFromJson", e, jso);
}
}
if (ObjectType.ACTIVITY.isMyType(jso)) {
return messageFromJsonActivity(jso);
} else if (ObjectType.compatibleWith(jso) == ObjectType.COMMENT) {
return messageFromJsonComment(jso);
} else {
return MbMessage.EMPTY;
}
}
private MbMessage messageFromJsonActivity(JSONObject activity) throws ConnectionException {
try {
ActivityType activityType = ActivityType.load(activity.getString("verb"));
String oid = activity.optString("id");
if (TextUtils.isEmpty(oid)) {
MyLog.d(this, "Pumpio activity has no id:" + activity.toString(2));
return MbMessage.EMPTY;
}
MbMessage message = messageFromJson(activity.getJSONObject("object"));
switch (activityType) {
case FAVORITE:
message.setFavorited(TriState.TRUE);
break;
case UNFAVORITE:
message.setFavorited(TriState.FALSE);
break;
case SHARE:
message.setReblogOid(oid);
break;
default:
break;
}
message.sentDate = dateFromJson(activity, "updated");
if (activity.has("actor")) {
message.setActor(userFromJson(activity.getJSONObject("actor")));
}
if (activity.has("to")) {
JSONObject to = activity.optJSONObject("to");
if ( to != null) {
message.recipient = userFromJson(to);
} else {
JSONArray arrayOfTo = activity.optJSONArray("to");
if (arrayOfTo != null && arrayOfTo.length() > 0) {
// TODO: handle multiple recipients
to = arrayOfTo.optJSONObject(0);
MbUser recipient = userFromJson(to);
if (!recipient.isEmpty()) {
message.recipient = recipient;
}
}
}
}
setVia(message, activity);
return message;
} catch (JSONException e) {
throw ConnectionException.loggedJsonException(this, "Parsing activity", e, activity);
}
}
private void setVia(MbMessage message, JSONObject activity) throws JSONException {
if (TextUtils.isEmpty(message.via) && activity.has(Properties.GENERATOR.code)) {
JSONObject generator = activity.getJSONObject(Properties.GENERATOR.code);
if (generator.has("displayName")) {
message.via = generator.getString("displayName");
}
}
}
private URL getImageUrl(JSONObject jso, String imageTag) throws JSONException {
if (jso.has(imageTag)) {
JSONObject attachment = jso.getJSONObject(imageTag);
return UrlUtils.fromJson(attachment, "url");
}
return null;
}
private MbMessage messageFromJsonComment(JSONObject jso) throws ConnectionException {
MbMessage message;
try {
String oid = jso.optString("id");
if (TextUtils.isEmpty(oid)) {
MyLog.d(TAG, "Pumpio object has no id:" + jso.toString(2));
return MbMessage.EMPTY;
}
message = MbMessage.fromOriginAndOid(data.getOriginId(), data.getAccountUserOid(), oid,
DownloadStatus.LOADED);
if (jso.has("author")) {
message.setAuthor(userFromJson(jso.getJSONObject("author")));
}
if (jso.has("content")) {
message.setBody(jso.getString("content"));
}
message.setUpdatedDate(dateFromJson(jso, "updated"));
if (message.getUpdatedDate() == 0) {
message.setUpdatedDate(dateFromJson(jso, "published"));
}
setVia(message, jso);
message.url = jso.optString("url");
if (jso.has("fullImage") || jso.has("image")) {
URL url = getImageUrl(jso, "fullImage");
if (url == null) {
url = getImageUrl(jso, "image");
}
MbAttachment mbAttachment = MbAttachment.fromUrlAndContentType(url, MyContentType.IMAGE);
if (mbAttachment.isValid()) {
message.attachments.add(mbAttachment);
} else {
MyLog.d(this, "Invalid attachment; " + jso.toString());
}
}
// If the Msg is a Reply to other message
if (jso.has("inReplyTo")) {
message.setInReplyTo(messageFromJson(jso.getJSONObject("inReplyTo")));
message.getInReplyTo().setSubscribedByMe(TriState.FALSE);
}
if (jso.has("replies")) {
JSONObject replies = jso.getJSONObject("replies");
if (replies.has("items")) {
JSONArray jArr = replies.getJSONArray("items");
for (int index = 0; index < jArr.length(); index++) {
try {
MbMessage item = messageFromJson(jArr.getJSONObject(index));
item.setSubscribedByMe(TriState.FALSE);
message.replies.add(item);
} catch (JSONException e) {
throw ConnectionException.loggedJsonException(this,
"Parsing list of replies", e, null);
}
}
}
}
} catch (JSONException e) {
throw ConnectionException.loggedJsonException(this, "Parsing comment", e, jso);
}
return message;
}
/**
* 2014-01-22 According to the crash reports, userId may not have "acct:" prefix
*/
public String userOidToUsername(String userId) {
String username = "";
if (!TextUtils.isEmpty(userId)) {
int indexOfColon = userId.indexOf(':');
if (indexOfColon >= 0) {
username = userId.substring(indexOfColon+1);
} else {
username = userId;
}
}
return username;
}
public String usernameToNickname(String username) {
String nickname = "";
if (!TextUtils.isEmpty(username)) {
int indexOfAt = username.indexOf('@');
if (indexOfAt > 0) {
nickname = username.substring(0, indexOfAt);
}
}
return nickname;
}
public String usernameToHost(String username) {
String host = "";
if (!TextUtils.isEmpty(username)) {
int indexOfAt = username.indexOf('@');
if (indexOfAt >= 0) {
host = username.substring(indexOfAt + 1);
}
}
return host;
}
@Override
public List<MbTimelineItem> search(TimelinePosition youngestPosition,
TimelinePosition oldestPosition, int limit, String searchQuery)
throws ConnectionException {
return new ArrayList<>();
}
@Override
public MbUser followUser(String userId, Boolean follow) throws ConnectionException {
return actOnUser(follow ? ActivityType.FOLLOW : ActivityType.STOP_FOLLOWING, userId);
}
private MbUser actOnUser(ActivityType activityType, String userId) throws ConnectionException {
return ActivitySender.fromId(this, userId).sendUser(activityType);
}
@Override
public MbUser getUser(String userId, String userName) throws ConnectionException {
ConnectionAndUrl conu = getConnectionAndUrlForUsername(ApiRoutineEnum.GET_USER,
MbUser.isOidReal(userId) ? userOidToUsername(userId) : userName);
JSONObject jso = conu.httpConnection.getRequest(conu.url);
MbUser mbUser = userFromJson(jso);
MyLog.v(this, "getUser oid='" + userId + "', userName='" + userName + "' -> " + mbUser.getRealName());
return mbUser;
}
protected OriginConnectionData getData() {
return data;
}
}