Java tutorial
/* * Copyright (c) 2010 Google 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 com.google.ytd.picasa; import com.google.appengine.api.blobstore.BlobstoreService; import com.google.appengine.api.blobstore.BlobstoreServiceFactory; import com.google.appengine.api.utils.SystemProperty; import com.google.gdata.client.photos.PicasawebService; import com.google.gdata.data.Link; import com.google.gdata.data.ParseSource; import com.google.gdata.data.PlainTextConstruct; import com.google.gdata.data.photos.AlbumEntry; import com.google.gdata.data.photos.AlbumFeed; import com.google.gdata.data.photos.GphotoAccess; import com.google.gdata.data.photos.PhotoEntry; import com.google.gdata.data.photos.UserFeed; import com.google.gdata.util.ParseUtil; import com.google.gdata.util.ServiceException; import com.google.inject.Inject; import com.google.ytd.dao.AdminConfigDao; import com.google.ytd.dao.AssignmentDao; import com.google.ytd.dao.DataChunkDao; import com.google.ytd.util.Util; import org.apache.commons.lang.StringEscapeUtils; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; /** * Class to handle interfacing with the Google Data Java Client Library's Picasa * support. */ public class PicasaApiHelper { private static final Logger LOG = Logger.getLogger(PicasaApiHelper.class.getName()); // CONSTANTS private static final String USER_FEED_URL = "http://picasaweb.google.com/data/feed/api/user/default"; private static final String ALBUM_FEED_URL = "http://picasaweb.google.com/data/feed/api/user/default?kind=album&max-results=1000"; private static final String RESUMABLE_UPLOADS_URL_FORMAT = "http://picasaweb.google.com/data/upload/resumable/photos/create-session/feed/api/user/default/albumid/%s"; private static final String UPLOAD_ENTRY_XML_FORMAT = "<?xml version='1.0' encoding='UTF-8'?>\n" + "<entry xmlns='http://www.w3.org/2005/Atom' " + "xmlns:georss='http://www.georss.org/georss' xmlns:gml='http://www.opengis.net/gml'>" + "\n <title>%s</title>" + "\n <summary>%s</summary>" + "\n <category scheme='http://schemas.google.com/g/2005#kind' " + "term='http://schemas.google.com/photos/2007#photo'/>" + "\n%s</entry>"; private static final String GEO_RSS_XML_FORMAT = "<georss:where><gml:Point><gml:pos>%f %f" + "</gml:pos></gml:Point></georss:where>"; // The connect + read timeout needs to be <= 10 seconds, due to App Engine limitations. private static final int CONNECT_TIMEOUT = 1000 * 2; // In milliseconds private static final int READ_TIMEOUT = 1000 * 8; // In milliseconds // The size of each resumable upload chunk we send. Due to App Engine limitations, this needs to // be less than 1MB. private static final int CHUNK_SIZE = 950 * 1024; // 950KB private PicasawebService service = null; private Util util = null; private AdminConfigDao adminConfigDao = null; private DataChunkDao dataChunkDao = null; @Inject public PicasaApiHelper(AdminConfigDao adminConfigDao, AssignmentDao assignmentDao, DataChunkDao dataChunkDao) { this.service = new PicasawebService(Util.CLIENT_ID_PREFIX + SystemProperty.applicationId.get()); this.util = Util.get(); this.adminConfigDao = adminConfigDao; this.dataChunkDao = dataChunkDao; setAuthSubTokenFromConfig(); service.setConnectTimeout(CONNECT_TIMEOUT); service.setReadTimeout(READ_TIMEOUT); } public boolean isAuthenticated() { return service.getAuthTokenFactory().getAuthToken() != null; } public void setAuthSubTokenFromConfig() { String authSubToken = adminConfigDao.getAdminConfig().getPicasaAuthSubToken(); if (!util.isNullOrEmpty(authSubToken)) { service.setAuthSubToken(authSubToken); } } public void setAuthSubToken(String token) { service.setAuthSubToken(token); } public String getCurrentUsername() throws IOException, ServiceException { try { UserFeed userFeed = service.getFeed(new URL(USER_FEED_URL), UserFeed.class); return userFeed.getUsername(); } catch (MalformedURLException e) { LOG.log(Level.WARNING, "", e); } return null; } public List<AlbumEntry> getAllAlbums() { ArrayList<AlbumEntry> albums = new ArrayList<AlbumEntry>(); try { URL feedUrl = new URL(ALBUM_FEED_URL); while (feedUrl != null) { UserFeed albumFeed = service.getFeed(feedUrl, UserFeed.class); albums.addAll(albumFeed.getAlbumEntries()); Link nextLink = albumFeed.getNextLink(); if (nextLink == null) { feedUrl = null; } else { feedUrl = new URL(nextLink.getHref()); } } return albums; } catch (MalformedURLException e) { LOG.log(Level.WARNING, "", e); } catch (IOException e) { LOG.log(Level.WARNING, "", e); } catch (ServiceException e) { LOG.log(Level.WARNING, "", e); } return null; } public String createAlbum(String title, String description, boolean privateAlbum) { LOG.info(String.format("Attempting to create %s Picasa album...", privateAlbum ? "private" : "public")); AlbumEntry album = new AlbumEntry(); if (privateAlbum) { album.setAccess(GphotoAccess.Value.PRIVATE); } else { album.setAccess(GphotoAccess.Value.PUBLIC); } album.setTitle(new PlainTextConstruct(title)); album.setDescription(new PlainTextConstruct(description)); try { AlbumEntry albumEntry = service.insert(new URL(USER_FEED_URL), album); String albumUrl = albumEntry.getFeedLink().getHref(); LOG.info(String.format("Created %s Picasa album: %s", privateAlbum ? "private" : "public", albumUrl)); return albumUrl; } catch (MalformedURLException e) { LOG.log(Level.WARNING, "", e); } catch (IOException e) { LOG.log(Level.WARNING, "", e); } catch (ServiceException e) { LOG.log(Level.WARNING, "", e); } return null; } public String moveToNewAlbum(String photoUrl, String newAlbumUrl) { LOG.info(String.format("Preparing to move '%s' to album '%s'...", photoUrl, newAlbumUrl)); // We only need to get the feed's id from the feed metadata here, so there's no need to // retrieve more than one entry. String urlWithParam; if (newAlbumUrl.indexOf("?") != -1) { urlWithParam = newAlbumUrl + "&max-results=1"; } else { urlWithParam = newAlbumUrl + "?max-results=1"; } AlbumFeed albumFeed = getAlbumFeedFromUrl(urlWithParam); if (albumFeed == null) { throw new IllegalArgumentException( String.format("Could not retrieve album from URL '%s'.", urlWithParam)); } String newAlbumId = albumFeed.getGphotoId(); com.google.gdata.data.photos.PhotoEntry photoEntry = getPhotoEntryFromUrl(photoUrl); if (photoEntry == null) { throw new IllegalArgumentException(String.format("Could not get photo from URL '%s'.", photoUrl)); } photoEntry.setAlbumId(newAlbumId); try { photoEntry = photoEntry.update(); LOG.info("Move was successful."); return photoEntry.getEditLink().getHref(); } catch (IOException e) { LOG.log(Level.WARNING, "", e); } catch (ServiceException e) { LOG.log(Level.WARNING, "", e); } return null; } private com.google.gdata.data.photos.PhotoEntry getPhotoEntryFromUrl(String photoUrl) { try { return service.getEntry(new URL(photoUrl), com.google.gdata.data.photos.PhotoEntry.class); } catch (MalformedURLException e) { LOG.log(Level.WARNING, "", e); } catch (IOException e) { LOG.log(Level.WARNING, "", e); } catch (ServiceException e) { LOG.log(Level.WARNING, "", e); } return null; } private AlbumFeed getAlbumFeedFromUrl(String albumUrl) { try { return service.getFeed(new URL(albumUrl), AlbumFeed.class); } catch (MalformedURLException e) { LOG.log(Level.WARNING, "", e); } catch (IOException e) { LOG.log(Level.WARNING, "", e); } catch (ServiceException e) { LOG.log(Level.WARNING, "", e); } return null; } public String getResumableUploadUrl(com.google.ytd.model.PhotoEntry photoEntry, String title, String description, String albumId, Double latitude, Double longitude) throws IllegalArgumentException { LOG.info(String.format("Resumable upload request.\nTitle: %s\nDescription: %s\nAlbum: %s", title, description, albumId)); // Picasa API resumable uploads are not currently documented publicly, but they're essentially // the same as what YouTube API offers: // http://code.google.com/apis/youtube/2.0/developers_guide_protocol_resumable_uploads.html // The Java client library does offer support for resumable uploads, but its use of threads // and some other assumptions makes it unsuitable for our purposes. try { URL url = new URL(String.format(RESUMABLE_UPLOADS_URL_FORMAT, albumId)); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setDoOutput(true); connection.setConnectTimeout(CONNECT_TIMEOUT); connection.setReadTimeout(READ_TIMEOUT); connection.setRequestMethod("POST"); // Set all the GData request headers. These strings should probably be moved to CONSTANTS. connection.setRequestProperty("Content-Type", "application/atom+xml;charset=UTF-8"); connection.setRequestProperty("Authorization", String.format("AuthSub token=\"%s\"", adminConfigDao.getAdminConfig().getPicasaAuthSubToken())); connection.setRequestProperty("GData-Version", "2.0"); connection.setRequestProperty("Slug", photoEntry.getOriginalFileName()); connection.setRequestProperty("X-Upload-Content-Type", photoEntry.getFormat()); connection.setRequestProperty("X-Upload-Content-Length", String.valueOf(photoEntry.getOriginalFileSize())); // If we're given lat/long then create the element to geotag the picture; otherwise, pass in // and empty string for no geotag. String geoRss = ""; if (latitude != null && longitude != null) { geoRss = String.format(GEO_RSS_XML_FORMAT, latitude, longitude); LOG.info("Geo RSS XML: " + geoRss); } String atomXml = String.format(UPLOAD_ENTRY_XML_FORMAT, StringEscapeUtils.escapeXml(title), StringEscapeUtils.escapeXml(description), geoRss); OutputStreamWriter writer = new OutputStreamWriter(connection.getOutputStream()); writer.write(atomXml); writer.close(); if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { String uploadUrl = connection.getHeaderField("Location"); if (util.isNullOrEmpty(uploadUrl)) { throw new IllegalArgumentException("No Location header found in HTTP response."); } else { LOG.info("Resumable upload URL is " + uploadUrl); return uploadUrl; } } else { LOG.warning(String.format("HTTP POST to %s returned status %d (%s).", url.toString(), connection.getResponseCode(), connection.getResponseMessage())); } } catch (MalformedURLException e) { LOG.log(Level.WARNING, "", e); throw new IllegalArgumentException(e); } catch (IOException e) { LOG.log(Level.WARNING, "", e); } return null; } public PhotoEntry doResumableUpload(com.google.ytd.model.PhotoEntry photoEntry) throws IllegalArgumentException { if (util.isNullOrEmpty(photoEntry.getResumableUploadUrl())) { throw new IllegalArgumentException(String .format("No resumable upload URL found for " + "PhotoEntry id '%s'.", photoEntry.getId())); } try { URL url = new URL(photoEntry.getResumableUploadUrl()); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setInstanceFollowRedirects(false); connection.setConnectTimeout(CONNECT_TIMEOUT); connection.setReadTimeout(READ_TIMEOUT); connection.setRequestMethod("PUT"); connection.setRequestProperty("Content-Range", "bytes */*"); // Response code 308 is specific to this use case and doesn't appear to have a // HttpURLConnection constant. if (connection.getResponseCode() == 308) { long previousByte = 0; String rangeHeader = connection.getHeaderField("Range"); if (!util.isNullOrEmpty(rangeHeader)) { LOG.info("Range header in 308 response is " + rangeHeader); String[] rangeHeaderSplits = rangeHeader.split("-", 2); if (rangeHeaderSplits.length == 2) { previousByte = Long.valueOf(rangeHeaderSplits[1]).longValue() + 1; } } connection = (HttpURLConnection) url.openConnection(); connection.setInstanceFollowRedirects(false); connection.setDoOutput(true); connection.setConnectTimeout(CONNECT_TIMEOUT); connection.setReadTimeout(READ_TIMEOUT); connection.setRequestMethod("PUT"); byte[] bytes; String contentRangeHeader; if (photoEntry.getBlobKey() != null) { long lastByte = previousByte + CHUNK_SIZE; if (lastByte > (photoEntry.getOriginalFileSize() - 1)) { lastByte = photoEntry.getOriginalFileSize() - 1; } contentRangeHeader = String.format("bytes %d-%d/%d", previousByte, lastByte, photoEntry.getOriginalFileSize()); BlobstoreService blobstoreService = BlobstoreServiceFactory.getBlobstoreService(); bytes = blobstoreService.fetchData(photoEntry.getBlobKey(), previousByte, lastByte); } else { bytes = dataChunkDao.getBytes(photoEntry.getId(), previousByte); if (bytes == null) { throw new IllegalArgumentException(String.format("PhotoEntry with id '%s' does not " + "have a valid blob key. Additionally, there is no DataChunk entry for the " + "initial byte '%d'.", photoEntry.getId(), previousByte)); } contentRangeHeader = String.format("bytes %d-%d/%d", previousByte, previousByte + bytes.length - 1, photoEntry.getOriginalFileSize()); } connection.setRequestProperty("Content-Length", String.valueOf(bytes.length)); LOG.info("Using the following for Content-Range header: " + contentRangeHeader); connection.setRequestProperty("Content-Range", contentRangeHeader); OutputStream outputStream = connection.getOutputStream(); outputStream.write(bytes); outputStream.close(); if (connection.getResponseCode() == HttpURLConnection.HTTP_CREATED) { LOG.info("Resumable upload is complete and successful."); return (PhotoEntry) ParseUtil.readEntry(new ParseSource(connection.getInputStream())); } } else if (connection.getResponseCode() == HttpURLConnection.HTTP_CREATED) { // It's possible that the Picasa upload associated with the specific resumable upload URL // had previously completed successfully. In that case, the response to the initial */* PUT // will be a 201 Created with the new PhotoEntry. This is probably an edge case. LOG.info("Resumable upload is complete and successful."); return (PhotoEntry) ParseUtil.readEntry(new ParseSource(connection.getInputStream())); } else { // The IllegalArgumentException should be treated by the calling code as // something that is not recoverable, which is to say the resumable upload attempt // should be stopped. throw new IllegalArgumentException(String.format("HTTP POST to %s returned status %d (%s).", url.toString(), connection.getResponseCode(), connection.getResponseMessage())); } } catch (MalformedURLException e) { LOG.log(Level.WARNING, "", e); throw new IllegalArgumentException(e); } catch (IOException e) { LOG.log(Level.WARNING, "", e); } catch (ServiceException e) { LOG.log(Level.WARNING, "", e); } return null; } }