| /* |
| * Copyright (C) 2008 The Android Open Source Project |
| * |
| * Licensed under the Eclipse Public License, Version 1.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.eclipse.org/org/documents/epl-v10.php |
| * |
| * 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.android.ide.eclipse.adt.internal.wizards.export; |
| |
| import com.android.ide.eclipse.adt.internal.project.ProjectHelper; |
| import com.android.ide.eclipse.adt.internal.sdk.Sdk; |
| import com.android.ide.eclipse.adt.internal.wizards.export.ExportWizard.ExportWizardPage; |
| |
| import org.eclipse.core.resources.IProject; |
| import org.eclipse.swt.SWT; |
| import org.eclipse.swt.custom.ScrolledComposite; |
| import org.eclipse.swt.events.ControlAdapter; |
| import org.eclipse.swt.events.ControlEvent; |
| import org.eclipse.swt.events.ModifyEvent; |
| import org.eclipse.swt.events.ModifyListener; |
| import org.eclipse.swt.events.SelectionAdapter; |
| import org.eclipse.swt.events.SelectionEvent; |
| import org.eclipse.swt.graphics.Rectangle; |
| import org.eclipse.swt.layout.GridData; |
| import org.eclipse.swt.layout.GridLayout; |
| import org.eclipse.swt.widgets.Button; |
| import org.eclipse.swt.widgets.Composite; |
| import org.eclipse.swt.widgets.FileDialog; |
| import org.eclipse.swt.widgets.Label; |
| import org.eclipse.swt.widgets.Text; |
| import org.eclipse.ui.forms.widgets.FormText; |
| |
| import java.io.File; |
| import java.io.FileInputStream; |
| import java.io.FileNotFoundException; |
| import java.io.IOException; |
| import java.security.KeyStore; |
| import java.security.KeyStoreException; |
| import java.security.NoSuchAlgorithmException; |
| import java.security.PrivateKey; |
| import java.security.UnrecoverableEntryException; |
| import java.security.KeyStore.PrivateKeyEntry; |
| import java.security.cert.CertificateException; |
| import java.security.cert.X509Certificate; |
| import java.util.Calendar; |
| import java.util.HashMap; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.Map.Entry; |
| |
| /** |
| * Final page of the wizard that checks the key and ask for the ouput location. |
| */ |
| final class KeyCheckPage extends ExportWizardPage { |
| |
| private final ExportWizard mWizard; |
| private PrivateKey mPrivateKey; |
| private X509Certificate mCertificate; |
| private Text mDestination; |
| private boolean mFatalSigningError; |
| private FormText mDetailText; |
| /** The Apk Config map for the current project */ |
| private Map<String, String> mApkConfig; |
| private ScrolledComposite mScrolledComposite; |
| |
| private String mKeyDetails; |
| private String mDestinationDetails; |
| |
| protected KeyCheckPage(ExportWizard wizard, String pageName) { |
| super(pageName); |
| mWizard = wizard; |
| |
| setTitle("Destination and key/certificate checks"); |
| setDescription(""); // TODO |
| } |
| |
| public void createControl(Composite parent) { |
| setErrorMessage(null); |
| setMessage(null); |
| |
| // build the ui. |
| Composite composite = new Composite(parent, SWT.NULL); |
| composite.setLayoutData(new GridData(GridData.FILL_BOTH)); |
| GridLayout gl = new GridLayout(3, false); |
| gl.verticalSpacing *= 3; |
| composite.setLayout(gl); |
| |
| GridData gd; |
| |
| new Label(composite, SWT.NONE).setText("Destination APK file:"); |
| mDestination = new Text(composite, SWT.BORDER); |
| mDestination.setLayoutData(gd = new GridData(GridData.FILL_HORIZONTAL)); |
| mDestination.addModifyListener(new ModifyListener() { |
| public void modifyText(ModifyEvent e) { |
| onDestinationChange(false /*forceDetailUpdate*/); |
| } |
| }); |
| final Button browseButton = new Button(composite, SWT.PUSH); |
| browseButton.setText("Browse..."); |
| browseButton.addSelectionListener(new SelectionAdapter() { |
| @Override |
| public void widgetSelected(SelectionEvent e) { |
| FileDialog fileDialog = new FileDialog(browseButton.getShell(), SWT.SAVE); |
| |
| fileDialog.setText("Destination file name"); |
| // get a default apk name based on the project |
| String filename = ProjectHelper.getApkFilename(mWizard.getProject(), |
| null /*config*/); |
| fileDialog.setFileName(filename); |
| |
| String saveLocation = fileDialog.open(); |
| if (saveLocation != null) { |
| mDestination.setText(saveLocation); |
| } |
| } |
| }); |
| |
| mScrolledComposite = new ScrolledComposite(composite, SWT.V_SCROLL); |
| mScrolledComposite.setLayoutData(gd = new GridData(GridData.FILL_BOTH)); |
| gd.horizontalSpan = 3; |
| mScrolledComposite.setExpandHorizontal(true); |
| mScrolledComposite.setExpandVertical(true); |
| |
| mDetailText = new FormText(mScrolledComposite, SWT.NONE); |
| mScrolledComposite.setContent(mDetailText); |
| |
| mScrolledComposite.addControlListener(new ControlAdapter() { |
| @Override |
| public void controlResized(ControlEvent e) { |
| updateScrolling(); |
| } |
| }); |
| |
| setControl(composite); |
| } |
| |
| @Override |
| void onShow() { |
| // fill the texts with information loaded from the project. |
| if ((mProjectDataChanged & DATA_PROJECT) != 0) { |
| // reset the destination from the content of the project |
| IProject project = mWizard.getProject(); |
| mApkConfig = Sdk.getCurrent().getProjectApkConfigs(project); |
| |
| String destination = ProjectHelper.loadStringProperty(project, |
| ExportWizard.PROPERTY_DESTINATION); |
| String filename = ProjectHelper.loadStringProperty(project, |
| ExportWizard.PROPERTY_FILENAME); |
| if (destination != null && filename != null) { |
| mDestination.setText(destination + File.separator + filename); |
| } |
| } |
| |
| // if anything change we basically reload the data. |
| if (mProjectDataChanged != 0) { |
| mFatalSigningError = false; |
| |
| // reset the wizard with no key/cert to make it not finishable, unless a valid |
| // key/cert is found. |
| mWizard.setSigningInfo(null, null); |
| mPrivateKey = null; |
| mCertificate = null; |
| mKeyDetails = null; |
| |
| if (mWizard.getKeystoreCreationMode() || mWizard.getKeyCreationMode()) { |
| int validity = mWizard.getValidity(); |
| StringBuilder sb = new StringBuilder( |
| String.format("<p>Certificate expires in %d years.</p>", |
| validity)); |
| |
| if (validity < 25) { |
| sb.append("<p>Make sure the certificate is valid for the planned lifetime of the product.</p>"); |
| sb.append("<p>If the certificate expires, you will be forced to sign your application with a different one.</p>"); |
| sb.append("<p>Applications cannot be upgraded if their certificate changes from one version to another, "); |
| sb.append("forcing a full uninstall/install, which will make the user lose his/her data.</p>"); |
| sb.append("<p>Android Market currently requires certificates to be valid until 2033.</p>"); |
| } |
| |
| mKeyDetails = sb.toString(); |
| } else { |
| try { |
| KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); |
| FileInputStream fis = new FileInputStream(mWizard.getKeystore()); |
| keyStore.load(fis, mWizard.getKeystorePassword().toCharArray()); |
| fis.close(); |
| PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry)keyStore.getEntry( |
| mWizard.getKeyAlias(), |
| new KeyStore.PasswordProtection( |
| mWizard.getKeyPassword().toCharArray())); |
| |
| if (entry != null) { |
| mPrivateKey = entry.getPrivateKey(); |
| mCertificate = (X509Certificate)entry.getCertificate(); |
| } else { |
| setErrorMessage("Unable to find key."); |
| |
| setPageComplete(false); |
| } |
| } catch (FileNotFoundException e) { |
| // this was checked at the first previous step and will not happen here, unless |
| // the file was removed during the export wizard execution. |
| onException(e); |
| } catch (KeyStoreException e) { |
| onException(e); |
| } catch (NoSuchAlgorithmException e) { |
| onException(e); |
| } catch (UnrecoverableEntryException e) { |
| onException(e); |
| } catch (CertificateException e) { |
| onException(e); |
| } catch (IOException e) { |
| onException(e); |
| } |
| |
| if (mPrivateKey != null && mCertificate != null) { |
| Calendar expirationCalendar = Calendar.getInstance(); |
| expirationCalendar.setTime(mCertificate.getNotAfter()); |
| Calendar today = Calendar.getInstance(); |
| |
| if (expirationCalendar.before(today)) { |
| mKeyDetails = String.format( |
| "<p>Certificate expired on %s</p>", |
| mCertificate.getNotAfter().toString()); |
| |
| // fatal error = nothing can make the page complete. |
| mFatalSigningError = true; |
| |
| setErrorMessage("Certificate is expired."); |
| setPageComplete(false); |
| } else { |
| // valid, key/cert: put it in the wizard so that it can be finished |
| mWizard.setSigningInfo(mPrivateKey, mCertificate); |
| |
| StringBuilder sb = new StringBuilder(String.format( |
| "<p>Certificate expires on %s.</p>", |
| mCertificate.getNotAfter().toString())); |
| |
| int expirationYear = expirationCalendar.get(Calendar.YEAR); |
| int thisYear = today.get(Calendar.YEAR); |
| |
| if (thisYear + 25 < expirationYear) { |
| // do nothing |
| } else { |
| if (expirationYear == thisYear) { |
| sb.append("<p>The certificate expires this year.</p>"); |
| } else { |
| int count = expirationYear-thisYear; |
| sb.append(String.format( |
| "<p>The Certificate expires in %1$s %2$s.</p>", |
| count, count == 1 ? "year" : "years")); |
| } |
| |
| sb.append("<p>Make sure the certificate is valid for the planned lifetime of the product.</p>"); |
| sb.append("<p>If the certificate expires, you will be forced to sign your application with a different one.</p>"); |
| sb.append("<p>Applications cannot be upgraded if their certificate changes from one version to another, "); |
| sb.append("forcing a full uninstall/install, which will make the user lose his/her data.</p>"); |
| sb.append("<p>Android Market currently requires certificates to be valid until 2033.</p>"); |
| } |
| |
| mKeyDetails = sb.toString(); |
| } |
| } else { |
| // fatal error = nothing can make the page complete. |
| mFatalSigningError = true; |
| } |
| } |
| } |
| |
| onDestinationChange(true /*forceDetailUpdate*/); |
| } |
| |
| /** |
| * Callback for destination field edition |
| * @param forceDetailUpdate if true, the detail {@link FormText} is updated even if a fatal |
| * error has happened in the signing. |
| */ |
| private void onDestinationChange(boolean forceDetailUpdate) { |
| if (mFatalSigningError == false) { |
| // reset messages for now. |
| setErrorMessage(null); |
| setMessage(null); |
| |
| String path = mDestination.getText().trim(); |
| |
| if (path.length() == 0) { |
| setErrorMessage("Enter destination for the APK file."); |
| // reset canFinish in the wizard. |
| mWizard.resetDestination(); |
| setPageComplete(false); |
| return; |
| } |
| |
| File file = new File(path); |
| if (file.isDirectory()) { |
| setErrorMessage("Destination is a directory."); |
| // reset canFinish in the wizard. |
| mWizard.resetDestination(); |
| setPageComplete(false); |
| return; |
| } |
| |
| File parentFolder = file.getParentFile(); |
| if (parentFolder == null || parentFolder.isDirectory() == false) { |
| setErrorMessage("Not a valid directory."); |
| // reset canFinish in the wizard. |
| mWizard.resetDestination(); |
| setPageComplete(false); |
| return; |
| } |
| |
| // display the list of files that will actually be created |
| Map<String, String[]> apkFileMap = getApkFileMap(file); |
| |
| // display them |
| boolean fileExists = false; |
| StringBuilder sb = new StringBuilder(String.format( |
| "<p>This will create the following files:</p>")); |
| |
| Set<Entry<String, String[]>> set = apkFileMap.entrySet(); |
| for (Entry<String, String[]> entry : set) { |
| String[] apkArray = entry.getValue(); |
| String filename = apkArray[ExportWizard.APK_FILE_DEST]; |
| File f = new File(parentFolder, filename); |
| if (f.isFile()) { |
| fileExists = true; |
| sb.append(String.format("<li>%1$s (WARNING: already exists)</li>", filename)); |
| } else if (f.isDirectory()) { |
| setErrorMessage(String.format("%1$s is a directory.", filename)); |
| // reset canFinish in the wizard. |
| mWizard.resetDestination(); |
| setPageComplete(false); |
| return; |
| } else { |
| sb.append(String.format("<li>%1$s</li>", filename)); |
| } |
| } |
| |
| mDestinationDetails = sb.toString(); |
| |
| // no error, set the destination in the wizard. |
| mWizard.setDestination(parentFolder, apkFileMap); |
| setPageComplete(true); |
| |
| // However, we should also test if the file already exists. |
| if (fileExists) { |
| setMessage("A destination file already exists.", WARNING); |
| } |
| |
| updateDetailText(); |
| } else if (forceDetailUpdate) { |
| updateDetailText(); |
| } |
| } |
| |
| /** |
| * Updates the scrollbar to match the content of the {@link FormText} or the new size |
| * of the {@link ScrolledComposite}. |
| */ |
| private void updateScrolling() { |
| if (mDetailText != null) { |
| Rectangle r = mScrolledComposite.getClientArea(); |
| mScrolledComposite.setMinSize(mDetailText.computeSize(r.width, SWT.DEFAULT)); |
| mScrolledComposite.layout(); |
| } |
| } |
| |
| private void updateDetailText() { |
| StringBuilder sb = new StringBuilder("<form>"); |
| if (mKeyDetails != null) { |
| sb.append(mKeyDetails); |
| } |
| |
| if (mDestinationDetails != null && mFatalSigningError == false) { |
| sb.append(mDestinationDetails); |
| } |
| |
| sb.append("</form>"); |
| |
| mDetailText.setText(sb.toString(), true /* parseTags */, |
| true /* expandURLs */); |
| |
| mDetailText.getParent().layout(); |
| |
| updateScrolling(); |
| |
| } |
| |
| /** |
| * Creates the list of destination filenames based on the content of the destination field |
| * and the list of APK configurations for the project. |
| * |
| * @param file File name from the destination field |
| * @return A list of destination filenames based <code>file</code> and the list of APK |
| * configurations for the project. |
| */ |
| private Map<String, String[]> getApkFileMap(File file) { |
| String filename = file.getName(); |
| |
| HashMap<String, String[]> map = new HashMap<String, String[]>(); |
| |
| // add the default APK filename |
| String[] apkArray = new String[ExportWizard.APK_COUNT]; |
| apkArray[ExportWizard.APK_FILE_SOURCE] = ProjectHelper.getApkFilename( |
| mWizard.getProject(), null /*config*/); |
| apkArray[ExportWizard.APK_FILE_DEST] = filename; |
| map.put(null, apkArray); |
| |
| // add the APKs for each APK configuration. |
| if (mApkConfig != null && mApkConfig.size() > 0) { |
| // remove the extension. |
| int index = filename.lastIndexOf('.'); |
| String base = filename.substring(0, index); |
| String extension = filename.substring(index); |
| |
| Set<Entry<String, String>> set = mApkConfig.entrySet(); |
| for (Entry<String, String> entry : set) { |
| apkArray = new String[ExportWizard.APK_COUNT]; |
| apkArray[ExportWizard.APK_FILE_SOURCE] = ProjectHelper.getApkFilename( |
| mWizard.getProject(), entry.getKey()); |
| apkArray[ExportWizard.APK_FILE_DEST] = base + "-" + entry.getKey() + extension; |
| map.put(entry.getKey(), apkArray); |
| } |
| } |
| |
| return map; |
| } |
| |
| @Override |
| protected void onException(Throwable t) { |
| super.onException(t); |
| |
| mKeyDetails = String.format("ERROR: %1$s", ExportWizard.getExceptionMessage(t)); |
| } |
| } |