package com.twasyl.slideshowfx.hosting.connector.drive;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse;
import com.google.api.client.http.FileContent;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.services.drive.Drive;
import com.google.api.services.drive.DriveScopes;
import com.google.api.services.drive.model.FileList;
import com.twasyl.slideshowfx.engine.presentation.PresentationEngine;
import com.twasyl.slideshowfx.global.configuration.GlobalConfiguration;
import com.twasyl.slideshowfx.hosting.connector.AbstractHostingConnector;
import com.twasyl.slideshowfx.hosting.connector.BasicHostingConnectorOptions;
import com.twasyl.slideshowfx.hosting.connector.drive.io.GoogleFile;
import com.twasyl.slideshowfx.hosting.connector.exceptions.HostingConnectorException;
import com.twasyl.slideshowfx.hosting.connector.io.RemoteFile;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.web.WebView;
import javafx.stage.Stage;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* This connector allows to interact with Google Drive.
*
* @author Thierry Wasylczenko
* @version 1.1
* @since SlideshowFX 1.0
*/
public class DriveHostingConnector extends AbstractHostingConnector<BasicHostingConnectorOptions> {
private static final Logger LOGGER = Logger.getLogger(DriveHostingConnector.class.getName());
private static final String SLIDESHOWFX_MIME_TYPE = "application/slideshowfx";
private static final String APPLICATION_NAME = "SlideshowFX";
private Credential credential;
public DriveHostingConnector() {
super("googledrive", "Google Drive", new GoogleFile());
this.setOptions(new BasicHostingConnectorOptions());
String configuration = GlobalConfiguration.getProperty(getConfigurationBaseName().concat(CONSUMER_KEY_PROPERTY_SUFFIX));
if (configuration != null && !configuration.trim().isEmpty()) {
this.getOptions().setConsumerKey(configuration.trim());
}
configuration = GlobalConfiguration.getProperty(getConfigurationBaseName().concat(CONSUMER_SECRET_PROPERTY_SUFFIX));
if (configuration != null && !configuration.trim().isEmpty()) {
this.getOptions().setConsumerSecret(configuration.trim());
}
configuration = GlobalConfiguration.getProperty(getConfigurationBaseName().concat(REDIRECT_URI_PROPERTY_SUFFIX));
if (configuration != null && !configuration.trim().isEmpty()) {
this.getOptions().setRedirectUri(configuration.trim());
}
configuration = GlobalConfiguration.getProperty(getConfigurationBaseName().concat(ACCESS_TOKEN_PROPERTY_SUFFIX));
if (configuration != null && !configuration.trim().isEmpty()) {
this.accessToken = configuration;
}
}
@Override
public Node getConfigurationUI() {
this.newOptions = new BasicHostingConnectorOptions();
this.newOptions.setConsumerKey(this.getOptions().getConsumerKey());
this.newOptions.setConsumerSecret(this.getOptions().getConsumerSecret());
this.newOptions.setRedirectUri(this.getOptions().getRedirectUri());
final Label consumerKeyLabel = new Label("Consumer key:");
final Label consumerSecretLabel = new Label("Consumer secret:");
final Label redirectUriLabel = new Label("Redirect URI:");
final TextField consumerKeyTextField = new TextField();
consumerKeyTextField.textProperty().bindBidirectional(this.newOptions.consumerKeyProperty());
consumerKeyTextField.setPrefColumnCount(20);
final TextField consumerSecretTextField = new TextField();
consumerSecretTextField.textProperty().bindBidirectional(this.newOptions.consumerSecretProperty());
consumerSecretTextField.setPrefColumnCount(20);
final TextField redirectUriTextField = new TextField();
redirectUriTextField.textProperty().bindBidirectional(this.newOptions.redirectUriProperty());
redirectUriTextField.setPrefColumnCount(20);
final HBox consumerKeyBox = new HBox(5, consumerKeyLabel, consumerKeyTextField);
consumerKeyBox.setAlignment(Pos.BASELINE_LEFT);
final HBox consumerSecretBox = new HBox(5, consumerSecretLabel, consumerSecretTextField);
consumerSecretBox.setAlignment(Pos.BASELINE_LEFT);
final HBox redirectUriBox = new HBox(5, redirectUriLabel, redirectUriTextField);
redirectUriBox.setAlignment(Pos.BASELINE_LEFT);
final VBox container = new VBox(5, consumerKeyBox, consumerSecretBox, redirectUriBox);
return container;
}
@Override
public void saveNewOptions() {
if (this.getNewOptions() != null) {
this.setOptions(this.getNewOptions());
if (this.getOptions().getConsumerKey() != null) {
GlobalConfiguration.setProperty(getConfigurationBaseName().concat(CONSUMER_KEY_PROPERTY_SUFFIX),
this.getOptions().getConsumerKey());
}
if (this.getOptions().getConsumerSecret() != null) {
GlobalConfiguration.setProperty(getConfigurationBaseName().concat(CONSUMER_SECRET_PROPERTY_SUFFIX),
this.getOptions().getConsumerSecret());
}
if (this.getOptions().getRedirectUri() != null) {
GlobalConfiguration.setProperty(getConfigurationBaseName().concat(REDIRECT_URI_PROPERTY_SUFFIX),
this.getOptions().getRedirectUri());
}
}
}
@Override
public void authenticate() throws HostingConnectorException {
if (this.getOptions().getConsumerKey() == null || this.getOptions().getConsumerSecret() == null) {
throw new HostingConnectorException(HostingConnectorException.MISSING_CONFIGURATION);
}
final HttpTransport httpTransport = new NetHttpTransport();
final JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();
final GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(httpTransport, jsonFactory,
this.getOptions().getConsumerKey(),
this.getOptions().getConsumerSecret(),
Arrays.asList(DriveScopes.DRIVE))
.setAccessType("online")
.setApprovalPrompt("auto")
.build();
final WebView browser = new WebView();
final Scene scene = new Scene(browser);
final Stage stage = new Stage();
browser.setPrefSize(500, 500);
browser.getEngine().locationProperty().addListener((locationProperty, oldLocation, newLocation) -> {
if (newLocation != null && newLocation.startsWith(this.getOptions().getRedirectUri())) {
try {
final Map<String, String> uriParameters = getURIParameters(new URI(newLocation));
if (uriParameters.containsKey("code")) {
final String authorizationCode = uriParameters.get("code");
final GoogleTokenResponse response = flow.newTokenRequest(authorizationCode)
.setRedirectUri(this.getOptions().getRedirectUri())
.execute();
this.credential = flow.createAndStoreCredential(response, this.getOptions().getConsumerKey());
this.accessToken = this.credential.getAccessToken();
}
} catch (URISyntaxException e) {
LOGGER.log(Level.SEVERE, "Error when parsing the redirect URI", e);
} catch (IOException e) {
LOGGER.log(Level.WARNING, "Failed to get access token", e);
this.accessToken = null;
} finally {
if (this.accessToken != null) {
GlobalConfiguration.setProperty(getConfigurationBaseName().concat(ACCESS_TOKEN_PROPERTY_SUFFIX),
this.accessToken);
}
stage.close();
}
}
});
browser.getEngine().load(flow.newAuthorizationUrl()
.setRedirectUri(this.getOptions().getRedirectUri())
.build());
stage.setScene(scene);
stage.setTitle("Authorize SlideshowFX in Google Drive");
stage.showAndWait();
if (!this.isAuthenticated())
throw new HostingConnectorException(HostingConnectorException.AUTHENTICATION_FAILURE);
}
@Override
public boolean checkAccessToken() {
boolean valid = false;
if (this.credential == null) {
this.credential = new GoogleCredential();
this.credential.setAccessToken(this.accessToken);
}
Drive service = new Drive.Builder(new NetHttpTransport(), JacksonFactory.getDefaultInstance(), this.credential)
.setApplicationName(APPLICATION_NAME)
.build();
try {
service.about()
.get()
.setFields("user, storageQuota")
.execute();
valid = true;
} catch (IOException e) {
LOGGER.log(Level.WARNING, "Can not determine if access token is valid", e);
}
return valid;
}
@Override
public void disconnect() {
}
@Override
public void upload(PresentationEngine engine, RemoteFile folder, boolean overwrite) throws HostingConnectorException, FileNotFoundException {
if (this.isAuthenticated()) {
Drive service = new Drive.Builder(new NetHttpTransport(), new JacksonFactory(), this.credential)
.setApplicationName(APPLICATION_NAME)
.build();
com.google.api.services.drive.model.File body;
if (overwrite) {
body = getFile(engine, folder);
// We need to delete the file before updating
if (body != null) {
try {
service.files().delete(body.getId()).execute();
} catch (IOException e) {
LOGGER.log(Level.WARNING, "Can not delete the existing file remotely", e);
}
}
body = this.buildFile(folder, engine);
} else {
body = this.buildFile(folder, engine);
if (this.fileExists(engine, folder)) {
final String nameWithoutExtension = engine.getArchive().getName().substring(0, engine.getArchive().getName().lastIndexOf("."));
final Calendar calendar = Calendar.getInstance();
body.setName(String.format("%1$s %2$tF %2$tT.%3$s", nameWithoutExtension, calendar, engine.getArchiveExtension()));
}
}
final FileContent mediaContent = new FileContent(SLIDESHOWFX_MIME_TYPE, engine.getArchive());
try {
service.files().create(body, mediaContent).setFields("id").execute();
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Can not upload presentation to Google Drive", e);
}
} else {
throw new HostingConnectorException(HostingConnectorException.NOT_AUTHENTICATED);
}
}
@Override
public File download(File destination, RemoteFile file) throws HostingConnectorException {
if (destination == null) throw new NullPointerException("The destination can not be null");
if (file == null) throw new NullPointerException("The file to download can not be null");
if (!destination.isDirectory()) throw new IllegalArgumentException("The destination is not a folder");
if (!(file instanceof GoogleFile)) throw new IllegalArgumentException("The file is not a GoogleFile");
File result;
if (this.isAuthenticated()) {
result = new File(destination, file.getName());
Drive service = new Drive.Builder(new NetHttpTransport(), new JacksonFactory(), this.credential)
.setApplicationName(APPLICATION_NAME)
.build();
try (final OutputStream out = new FileOutputStream(result)) {
service.files().get(((GoogleFile) file).getId()).executeMediaAndDownloadTo(out);
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Can not download the file", e);
result = null;
}
} else {
throw new HostingConnectorException(HostingConnectorException.NOT_AUTHENTICATED);
}
return result;
}
@Override
public List<RemoteFile> list(RemoteFile parent, boolean includeFolders, boolean includePresentations) throws HostingConnectorException {
if (parent == null) throw new NullPointerException("The parent can not be null");
if (!(parent instanceof GoogleFile))
throw new IllegalArgumentException("The given parent must be a GoogleFile");
final List<RemoteFile> folders = new ArrayList<>();
if (this.isAuthenticated()) {
final Drive service = new Drive.Builder(new NetHttpTransport(), new JacksonFactory(), this.credential)
.setApplicationName(APPLICATION_NAME)
.build();
try {
final StringBuilder query = new StringBuilder();
if (includeFolders && includePresentations)
query.append("(mimeType = 'application/vnd.google-apps.folder' or mimeType = '")
.append(SLIDESHOWFX_MIME_TYPE).append("') and ");
if (includeFolders && !includePresentations)
query.append("mimeType = 'application/vnd.google-apps.folder' and ");
if (!includeFolders && includePresentations)
query.append("mimeType = '").append(SLIDESHOWFX_MIME_TYPE).append("' and ");
query.append("not trashed ")
.append("and '").append(((GoogleFile) parent).getId()).append("' in parents");
final FileList files = service.files()
.list()
.setQ(query.toString())
.execute();
GoogleFile child;
for (com.google.api.services.drive.model.File reference : files.getFiles()) {
child = new GoogleFile((GoogleFile) parent, reference.getName(), reference.getId());
child.setDownloadUrl(reference.getWebContentLink());
folders.add(child);
}
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Can not list folders of Google Drive", e);
}
} else {
throw new HostingConnectorException(HostingConnectorException.NOT_AUTHENTICATED);
}
return folders;
}
@Override
public boolean fileExists(PresentationEngine engine, RemoteFile destination) throws HostingConnectorException {
if (engine == null) throw new NullPointerException("The engine can not be null");
if (engine.getArchive() == null) throw new NullPointerException("The archive file can not be null");
if (destination == null) throw new NullPointerException("The destination can not be null");
if (!(destination instanceof GoogleFile))
throw new IllegalArgumentException("The given destination must be a GoogleFile");
boolean exists;
if (this.isAuthenticated()) {
exists = getFile(engine, destination) != null;
} else {
throw new HostingConnectorException(HostingConnectorException.NOT_AUTHENTICATED);
}
return exists;
}
/**
* Get the {@link com.google.api.services.drive.model.File file} present remotely in the provided folder.
*
* @param engine The presentation to find remotely.
* @param folder The folder in which the search will be performed.
* @return The corresponding {@link com.google.api.services.drive.model.File} to the presentation or {@code null} if not found.
*/
protected com.google.api.services.drive.model.File getFile(final PresentationEngine engine, final RemoteFile folder) {
if (engine == null) throw new NullPointerException("The engine can not be null");
if (engine.getArchive() == null) throw new NullPointerException("The archive file can not be null");
if (folder == null) throw new NullPointerException("The folder can not be null");
if (!(folder instanceof GoogleFile))
throw new IllegalArgumentException("The given folder must be a GoogleFile");
com.google.api.services.drive.model.File result = null;
final Drive service = new Drive.Builder(new NetHttpTransport(), new JacksonFactory(), this.credential)
.setApplicationName(APPLICATION_NAME)
.build();
try {
final StringBuilder query = new StringBuilder()
.append("mimeType != 'application/vnd.google-apps.folder'")
.append(" and not trashed")
.append(String.format(" and '%1$s' in parents", ((GoogleFile) folder).getId()))
.append(String.format(" and name = '%1$s'", engine.getArchive().getName()));
final FileList files = service.files()
.list()
.setFields("nextPageToken, files(id, name)")
.setSpaces("drive")
.setQ(query.toString())
.execute();
if (!files.getFiles().isEmpty()) {
result = files.getFiles().get(0);
}
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Can not list files of Google Drive", e);
}
return result;
}
/**
* Builds an instance of {@link com.google.api.services.drive.model.File} and initialized correctly with SlideshowFX
* information.
*
* @param destination The location where the file will be created.
* @param engine
* @return A well constructed instance of {@link com.google.api.services.drive.model.File}.
*/
protected com.google.api.services.drive.model.File buildFile(RemoteFile destination, PresentationEngine engine) {
com.google.api.services.drive.model.File body;
body = new com.google.api.services.drive.model.File();
body.setMimeType(SLIDESHOWFX_MIME_TYPE);
if (destination instanceof GoogleFile) {
final GoogleFile googleFolder = (GoogleFile) destination;
body.setParents(Arrays.asList(googleFolder.getId()));
}
body.setName(engine.getArchive().getName());
return body;
}
}