package fabric.beta.publisher; import com.google.common.base.Strings; import hudson.EnvVars; import hudson.Extension; import hudson.FilePath; import hudson.Launcher; import hudson.model.*; import hudson.scm.ChangeLogSet; import hudson.tasks.BuildStepDescriptor; import hudson.tasks.BuildStepMonitor; import hudson.tasks.Publisher; import hudson.tasks.Recorder; import hudson.util.FormValidation; import jenkins.tasks.SimpleBuildStep; import net.sf.json.JSONObject; import org.jenkinsci.Symbol; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.StaplerRequest; import javax.annotation.Nonnull; import java.io.File; import java.io.IOException; import java.io.InterruptedIOException; import java.io.PrintStream; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static fabric.beta.publisher.ChangelogReader.getChangeLogSet; import static fabric.beta.publisher.CommandRunner.runCommand; import static fabric.beta.publisher.FileUtils.*; import static fabric.beta.publisher.ReleaseNotesFormatter.getReleaseNotes; public class FabricBetaPublisher extends Recorder implements SimpleBuildStep { static final String RELEASE_NOTES_TYPE_FILE = "RELEASE_NOTES_FILE"; static final String RELEASE_NOTES_TYPE_PARAMETER = "RELEASE_NOTES_PARAMETER"; static final String RELEASE_NOTES_TYPE_CHANGELOG = "RELEASE_NOTES_FROM_CHANGELOG"; static final String RELEASE_NOTES_TYPE_NONE = "RELEASE_NOTES_NONE"; private static final String ENV_VAR_BUILD_URL = "FABRIC_BETA_BUILD_URL"; private static final String NOTIFY_TESTERS_TYPE_NONE = "NOTIFY_TESTERS_NONE"; private static final String NOTIFY_TESTERS_TYPE_EMAILS = "NOTIFY_TESTERS_EMAILS"; private static final String NOTIFY_TESTERS_GROUP = "NOTIFY_TESTERS_GROUP"; private final String apiKey; private final String buildSecret; private final String releaseNotesType; private final String notifyTestersType; private final String releaseNotesParameter; private final String releaseNotesFile; private final String apkPath; private final String testersEmails; private final String testersGroup; private final String organization; private final boolean useAntStyleInclude; @DataBoundConstructor public FabricBetaPublisher(String apiKey, String buildSecret, String releaseNotesType, String notifyTestersType, String releaseNotesParameter, String releaseNotesFile, String apkPath, String testersEmails, String testersGroup, String organization, boolean useAntStyleInclude) { this.apiKey = apiKey; this.buildSecret = buildSecret; this.releaseNotesType = releaseNotesType == null ? RELEASE_NOTES_TYPE_NONE : releaseNotesType; this.notifyTestersType = notifyTestersType == null ? NOTIFY_TESTERS_TYPE_NONE : notifyTestersType; this.releaseNotesParameter = releaseNotesParameter; this.releaseNotesFile = releaseNotesFile; this.testersEmails = testersEmails; this.testersGroup = testersGroup; this.organization = organization; this.apkPath = apkPath; this.useAntStyleInclude = useAntStyleInclude; } @Override public boolean perform(@Nonnull AbstractBuild<?, ?> build, @Nonnull Launcher launcher, @Nonnull BuildListener listener) throws IOException, InterruptedException { PrintStream logger = listener.getLogger(); Result result = build.getResult(); if (result != null && result.isWorseOrEqualTo(Result.FAILURE)) { logger.println("Aborting Fabric Beta upload since build has failed."); return false; } return publishFabric(build, build.getEnvironment(listener), build.getWorkspace(), logger, getChangeLogSet(build)); } @Override public void perform(@Nonnull Run build, @Nonnull FilePath workspace, @Nonnull Launcher launcher, @Nonnull TaskListener listener) throws InterruptedException, IOException { PrintStream logger = listener.getLogger(); Result result = build.getResult(); if (result != null && result.isWorseOrEqualTo(Result.FAILURE)) { logger.println("Aborting Fabric Beta upload since build has failed."); build.setResult(Result.FAILURE); return; } boolean success = publishFabric(build, build.getEnvironment(listener), workspace, logger, getChangeLogSet(build)); if (!success) { build.setResult(Result.FAILURE); } } /** * @return true if all APKs have been published successfully. */ private boolean publishFabric(Run build, EnvVars environment, FilePath workspace, PrintStream logger, ChangeLogSet<? extends ChangeLogSet.Entry> changeLogSet) throws InterruptedException, IOException { logger.println("Fabric Beta Publisher Plugin:"); File manifestFile = getManifestFile(); File crashlyticsToolsFile = prepareCrashlytics(logger, manifestFile); if (crashlyticsToolsFile == null) { return false; } String releaseNotes = getReleaseNotes( changeLogSet, releaseNotesType, releaseNotesParameter, releaseNotesFile, environment, workspace); EnvVarsAction envVarsAction = null; if (!Strings.isNullOrEmpty(organization)) { envVarsAction = new EnvVarsAction(); } else { logger.println("Skipped constructing Fabric Beta link because organization is not set."); } List<FilePath> apkFilePaths = getApkFilePaths(environment, workspace); boolean success = !apkFilePaths.isEmpty(); for (int apkIndex = 0; apkIndex < apkFilePaths.size(); apkIndex++) { success &= uploadApkFile(envVarsAction, apkIndex, environment, logger, manifestFile, crashlyticsToolsFile, releaseNotes, apkFilePaths.get(apkIndex)); } if (envVarsAction != null) { build.addAction(envVarsAction); } FileUtils.delete(logger, manifestFile, crashlyticsToolsFile); return success; } /** * @return true if APK file has been uploaded successfuly. */ private boolean uploadApkFile(EnvVarsAction envVarsAction, int apkIndex, EnvVars environment, PrintStream logger, File manifestFile, File crashlyticsToolsFile, String releaseNotes, FilePath apkFilePath) throws IOException, InterruptedException { File apkFile; boolean shouldDeleteApk; if (apkFilePath.isRemote()) { apkFile = FileUtils.createTemporaryUploadFile(apkFilePath.read()); shouldDeleteApk = true; } else { apkFile = new File(apkFilePath.toURI()); shouldDeleteApk = false; } if (envVarsAction != null) { AppRelease appRelease = AppRelease.from(apkFile); if (appRelease == null) { throw new InterruptedIOException("Could not read APK properties for apk " + apkFile); } else { saveBuildLinks(logger, envVarsAction, apkIndex, appRelease.buildLink(organization)); } } List<String> command = buildCrashlyticsCommand(environment, manifestFile, apkFile, crashlyticsToolsFile, releaseNotes); boolean success = runCommand(logger, command); if (shouldDeleteApk) { FileUtils.delete(logger, apkFile); } return success; } private void saveBuildLinks(PrintStream logger, EnvVarsAction envVarsAction, int apkIndex, String buildUrl) { if (apkIndex == 0) { envVarsAction.add(logger, ENV_VAR_BUILD_URL, buildUrl); } envVarsAction.add(logger, ENV_VAR_BUILD_URL + "_" + apkIndex, buildUrl); } private File prepareCrashlytics(PrintStream logger, File manifestFile) throws IOException, InterruptedException { try { File crashlyticsZip = downloadCrashlyticsTools(logger); return extractCrashlyticsJar(crashlyticsZip, logger); } catch (IOException e) { logger.println("Error downloading crashlytics-devtools.jar: " + e.getMessage()); FileUtils.delete(logger, manifestFile); } return null; } private List<FilePath> getApkFilePaths(EnvVars environment, FilePath workspace) throws IOException, InterruptedException { if (useAntStyleInclude) { return Arrays.asList(workspace.list(expand(environment, apkPath))); } else { List<FilePath> filePaths = new ArrayList<>(); for (String oneApkPath : apkPath.split(",")) { filePaths.add(new FilePath(workspace, expand(environment, oneApkPath.trim()))); } return filePaths; } } private List<String> buildCrashlyticsCommand(EnvVars environment, File manifestFile, File apkFile, File toolsFile, String releaseNotes) { List<String> command = new ArrayList<>(); command.add("java"); if (System.getProperty("http.nonProxyHosts") != null) { command.add("-Dhttp.nonProxyHosts=\"" + System.getProperty("http.nonProxyHosts") + "\""); } if (System.getProperty("http.proxyHost") != null) { command.add("-Dhttp.proxyHost=" + System.getProperty("http.proxyHost")); } if (System.getProperty("http.proxyPort") != null) { command.add("-Dhttp.proxyPort=" + System.getProperty("http.proxyPort")); } if (System.getProperty("https.proxyHost") != null) { command.add("-Dhttps.proxyHost=" + System.getProperty("https.proxyHost")); } if (System.getProperty("https.proxyPort") != null) { command.add("-Dhttps.proxyPort=" + System.getProperty("https.proxyPort")); } command.add("-jar"); command.add(toolsFile.getPath()); command.add("-androidRes"); command.add("."); command.add("-apiKey"); command.add(expand(environment, apiKey)); command.add("-apiSecret"); command.add(expand(environment, buildSecret)); command.add("-androidManifest"); command.add(manifestFile.getPath()); command.add("-uploadDist"); command.add(apkFile.getPath()); command.add("-betaDistributionNotifications"); command.add(String.valueOf(shouldSendNotifications())); if (NOTIFY_TESTERS_TYPE_EMAILS.equals(notifyTestersType) && !Strings.isNullOrEmpty(testersEmails)) { command.add("-betaDistributionEmails"); command.add(expand(environment, testersEmails)); } if (NOTIFY_TESTERS_GROUP.equals(notifyTestersType) && !Strings.isNullOrEmpty(testersGroup)) { command.add("-betaDistributionGroupAliases"); command.add(expand(environment, testersGroup)); } if (!Strings.isNullOrEmpty(releaseNotes)) { command.add("-betaDistributionReleaseNotes"); command.add(releaseNotes); } return command; } private String expand(EnvVars environment, String s) { return environment.expand(s); } private boolean shouldSendNotifications() { return !notifyTestersType.equalsIgnoreCase(NOTIFY_TESTERS_TYPE_NONE); } @Override public DescriptorImpl getDescriptor() { return (DescriptorImpl) super.getDescriptor(); } @Override public BuildStepMonitor getRequiredMonitorService() { return BuildStepMonitor.NONE; } @SuppressWarnings("unused") public String getApiKey() { return apiKey; } @SuppressWarnings("unused") public String getBuildSecret() { return buildSecret; } @SuppressWarnings("unused") public String getApkPath() { return apkPath; } @SuppressWarnings("unused") public boolean isUseAntStyleInclude() { return useAntStyleInclude; } @SuppressWarnings("unused") public String getTestersGroup() { return testersGroup; } @SuppressWarnings("unused") public String getOrganization() { return organization; } @SuppressWarnings("unused") public String getTestersEmails() { return testersEmails; } @SuppressWarnings("unused") public String isReleaseNotesType(String releaseNotesType) { return this.releaseNotesType.equalsIgnoreCase(releaseNotesType) ? "true" : ""; } @SuppressWarnings("unused") public String isNotifyTestersType(String notifyTestersType) { return this.notifyTestersType.equalsIgnoreCase(notifyTestersType) ? "true" : ""; } @SuppressWarnings("unused") public String getReleaseNotesType() { return releaseNotesType; } @SuppressWarnings("unused") public String getNotifyTestersType() { return notifyTestersType; } @SuppressWarnings("unused") public String getReleaseNotesParameter() { return releaseNotesParameter; } @SuppressWarnings("unused") public String getReleaseNotesFile() { return releaseNotesFile; } @Extension @Symbol("fabric") public static final class DescriptorImpl extends BuildStepDescriptor<Publisher> { public DescriptorImpl() { load(); } @SuppressWarnings("unused") public FormValidation doCheckApiKey(@QueryParameter String value) { if (value.length() == 0) { return FormValidation.error("Please input a Fabric API key"); } return FormValidation.ok(); } @SuppressWarnings("unused") public FormValidation doCheckBuildSecret(@QueryParameter String value) { if (value.length() == 0) { return FormValidation.error("Please input a Fabric build secret"); } return FormValidation.ok(); } @SuppressWarnings("unused") public FormValidation doCheckApkPath(@QueryParameter String value) { if (value.length() == 0) { return FormValidation.error("Please input an .apk file path"); } return FormValidation.ok(); } public boolean isApplicable(Class<? extends AbstractProject> aClass) { return true; } public String getDisplayName() { return "Upload .apk to Fabric Beta"; } @Override public boolean configure(StaplerRequest req, JSONObject formData) throws FormException { save(); return super.configure(req, formData); } } }