FileDocCategorySizeDatePackage
KeyCheckPage.javaAPI DocAndroid 1.5 API18758Wed May 06 22:41:10 BST 2009com.android.ide.eclipse.adt.project.export

KeyCheckPage.java

/*
 * 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.project.export;

import com.android.ide.eclipse.adt.project.ProjectHelper;
import com.android.ide.eclipse.adt.project.export.ExportWizard.ExportWizardPage;
import com.android.ide.eclipse.adt.sdk.Sdk;

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));
    }
}