1/*
2 * Copyright (C) 2016 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License
15 */
16
17package com.android.packageinstaller.wear;
18
19import android.annotation.TargetApi;
20import android.app.PendingIntent;
21import android.content.BroadcastReceiver;
22import android.content.Context;
23import android.content.Intent;
24import android.content.IntentFilter;
25import android.content.IntentSender;
26import android.content.pm.PackageInstaller;
27import android.os.Build;
28import android.os.ParcelFileDescriptor;
29import android.util.Log;
30
31import java.io.IOException;
32import java.util.HashMap;
33import java.util.List;
34import java.util.Map;
35
36/**
37 * Implementation of package manager installation using modern PackageInstaller api.
38 *
39 * Heavily copied from Wearsky/Finsky implementation
40 */
41@TargetApi(Build.VERSION_CODES.LOLLIPOP)
42public class PackageInstallerImpl {
43    private static final String TAG = "PackageInstallerImpl";
44
45    /** Intent actions used for broadcasts from PackageInstaller back to the local receiver */
46    private static final String ACTION_INSTALL_COMMIT =
47            "com.android.vending.INTENT_PACKAGE_INSTALL_COMMIT";
48
49    private final Context mContext;
50    private final PackageInstaller mPackageInstaller;
51    private final Map<String, PackageInstaller.SessionInfo> mSessionInfoMap;
52    private final Map<String, PackageInstaller.Session> mOpenSessionMap;
53
54    public PackageInstallerImpl(Context context) {
55        mContext = context.getApplicationContext();
56        mPackageInstaller = mContext.getPackageManager().getPackageInstaller();
57
58        // Capture a map of known sessions
59        // This list will be pruned a bit later (stale sessions will be canceled)
60        mSessionInfoMap = new HashMap<String, PackageInstaller.SessionInfo>();
61        List<PackageInstaller.SessionInfo> mySessions = mPackageInstaller.getMySessions();
62        for (int i = 0; i < mySessions.size(); i++) {
63            PackageInstaller.SessionInfo sessionInfo = mySessions.get(i);
64            String packageName = sessionInfo.getAppPackageName();
65            PackageInstaller.SessionInfo oldInfo = mSessionInfoMap.put(packageName, sessionInfo);
66
67            // Checking for old info is strictly for logging purposes
68            if (oldInfo != null) {
69                Log.w(TAG, "Multiple sessions for " + packageName + " found. Removing " + oldInfo
70                        .getSessionId() + " & keeping " + mySessions.get(i).getSessionId());
71            }
72        }
73        mOpenSessionMap = new HashMap<String, PackageInstaller.Session>();
74    }
75
76    /**
77     * This callback will be made after an installation attempt succeeds or fails.
78     */
79    public interface InstallListener {
80        /**
81         * This callback signals that preflight checks have succeeded and installation
82         * is beginning.
83         */
84        void installBeginning();
85
86        /**
87         * This callback signals that installation has completed.
88         */
89        void installSucceeded();
90
91        /**
92         * This callback signals that installation has failed.
93         */
94        void installFailed(int errorCode, String errorDesc);
95    }
96
97    /**
98     * This is a placeholder implementation that bundles an entire "session" into a single
99     * call. This will be replaced by more granular versions that allow longer session lifetimes,
100     * download progress tracking, etc.
101     *
102     * This must not be called on main thread.
103     */
104    public void install(final String packageName, ParcelFileDescriptor parcelFileDescriptor,
105            final InstallListener callback) {
106        // 0. Generic try/catch block because I am not really sure what exceptions (other than
107        // IOException) might be thrown by PackageInstaller and I want to handle them
108        // at least slightly gracefully.
109        try {
110            // 1. Create or recover a session, and open it
111            // Try recovery first
112            PackageInstaller.Session session = null;
113            PackageInstaller.SessionInfo sessionInfo = mSessionInfoMap.get(packageName);
114            if (sessionInfo != null) {
115                // See if it's openable, or already held open
116                session = getSession(packageName);
117            }
118            // If open failed, or there was no session, create a new one and open it.
119            // If we cannot create or open here, the failure is terminal.
120            if (session == null) {
121                try {
122                    innerCreateSession(packageName);
123                } catch (IOException ioe) {
124                    Log.e(TAG, "Can't create session for " + packageName + ": " + ioe.getMessage());
125                    callback.installFailed(InstallerConstants.ERROR_INSTALL_CREATE_SESSION,
126                            "Could not create session");
127                    mSessionInfoMap.remove(packageName);
128                    return;
129                }
130                sessionInfo = mSessionInfoMap.get(packageName);
131                try {
132                    session = mPackageInstaller.openSession(sessionInfo.getSessionId());
133                    mOpenSessionMap.put(packageName, session);
134                } catch (SecurityException se) {
135                    Log.e(TAG, "Can't open session for " + packageName + ": " + se.getMessage());
136                    callback.installFailed(InstallerConstants.ERROR_INSTALL_OPEN_SESSION,
137                            "Can't open session");
138                    mSessionInfoMap.remove(packageName);
139                    return;
140                }
141            }
142
143            // 2. Launch task to handle file operations.
144            InstallTask task = new InstallTask( mContext, packageName, parcelFileDescriptor,
145                    callback, session,
146                    getCommitCallback(packageName, sessionInfo.getSessionId(), callback));
147            task.execute();
148            if (task.isError()) {
149                cancelSession(sessionInfo.getSessionId(), packageName);
150            }
151        } catch (Exception e) {
152            Log.e(TAG, "Unexpected exception while installing " + packageName);
153            callback.installFailed(InstallerConstants.ERROR_INSTALL_SESSION_EXCEPTION,
154                    "Unexpected exception while installing " + packageName);
155        }
156    }
157
158    /**
159     * Retrieve an existing session. Will open if needed, but does not attempt to create.
160     */
161    private PackageInstaller.Session getSession(String packageName) {
162        // Check for already-open session
163        PackageInstaller.Session session = mOpenSessionMap.get(packageName);
164        if (session != null) {
165            try {
166                // Probe the session to ensure that it's still open. This may or may not
167                // throw (if non-open), but it may serve as a canary for stale sessions.
168                session.getNames();
169                return session;
170            } catch (IOException ioe) {
171                Log.e(TAG, "Stale open session for " + packageName + ": " + ioe.getMessage());
172                mOpenSessionMap.remove(packageName);
173            } catch (SecurityException se) {
174                Log.e(TAG, "Stale open session for " + packageName + ": " + se.getMessage());
175                mOpenSessionMap.remove(packageName);
176            }
177        }
178        // Check to see if this is a known session
179        PackageInstaller.SessionInfo sessionInfo = mSessionInfoMap.get(packageName);
180        if (sessionInfo == null) {
181            return null;
182        }
183        // Try to open it. If we fail here, assume that the SessionInfo was stale.
184        try {
185            session = mPackageInstaller.openSession(sessionInfo.getSessionId());
186        } catch (SecurityException se) {
187            Log.w(TAG, "SessionInfo was stale for " + packageName + " - deleting info");
188            mSessionInfoMap.remove(packageName);
189            return null;
190        } catch (IOException ioe) {
191            Log.w(TAG, "IOException opening old session for " + ioe.getMessage()
192                    + " - deleting info");
193            mSessionInfoMap.remove(packageName);
194            return null;
195        }
196        mOpenSessionMap.put(packageName, session);
197        return session;
198    }
199
200    /** This version throws an IOException when the session cannot be created */
201    private void innerCreateSession(String packageName) throws IOException {
202        if (mSessionInfoMap.containsKey(packageName)) {
203            Log.w(TAG, "Creating session for " + packageName + " when one already exists");
204            return;
205        }
206        PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
207                PackageInstaller.SessionParams.MODE_FULL_INSTALL);
208        params.setAppPackageName(packageName);
209
210        // IOException may be thrown at this point
211        int sessionId = mPackageInstaller.createSession(params);
212        PackageInstaller.SessionInfo sessionInfo = mPackageInstaller.getSessionInfo(sessionId);
213        mSessionInfoMap.put(packageName, sessionInfo);
214    }
215
216    /**
217     * Cancel a session based on its sessionId. Package name is for logging only.
218     */
219    private void cancelSession(int sessionId, String packageName) {
220        // Close if currently held open
221        closeSession(packageName);
222        // Remove local record
223        mSessionInfoMap.remove(packageName);
224        try {
225            mPackageInstaller.abandonSession(sessionId);
226        } catch (SecurityException se) {
227            // The session no longer exists, so we can exit quietly.
228            return;
229        }
230    }
231
232    /**
233     * Close a session if it happens to be held open.
234     */
235    private void closeSession(String packageName) {
236        PackageInstaller.Session session = mOpenSessionMap.remove(packageName);
237        if (session != null) {
238            // Unfortunately close() is not idempotent. Try our best to make this safe.
239            try {
240                session.close();
241            } catch (Exception e) {
242                Log.w(TAG, "Unexpected error closing session for " + packageName + ": "
243                        + e.getMessage());
244            }
245        }
246    }
247
248    /**
249     * Creates a commit callback for the package install that's underway. This will be called
250     * some time after calling session.commit() (above).
251     */
252    private IntentSender getCommitCallback(final String packageName, final int sessionId,
253            final InstallListener callback) {
254        // Create a single-use broadcast receiver
255        BroadcastReceiver broadcastReceiver = new BroadcastReceiver() {
256            @Override
257            public void onReceive(Context context, Intent intent) {
258                mContext.unregisterReceiver(this);
259                handleCommitCallback(intent, packageName, sessionId, callback);
260            }
261        };
262        // Create a matching intent-filter and register the receiver
263        String action = ACTION_INSTALL_COMMIT + "." + packageName;
264        IntentFilter intentFilter = new IntentFilter();
265        intentFilter.addAction(action);
266        mContext.registerReceiver(broadcastReceiver, intentFilter);
267
268        // Create a matching PendingIntent and use it to generate the IntentSender
269        Intent broadcastIntent = new Intent(action);
270        PendingIntent pendingIntent = PendingIntent.getBroadcast(mContext, packageName.hashCode(),
271                broadcastIntent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
272        return pendingIntent.getIntentSender();
273    }
274
275    /**
276     * Examine the extras to determine information about the package update/install, decode
277     * the result, and call the appropriate callback.
278     *
279     * @param intent The intent, which the PackageInstaller will have added Extras to
280     * @param packageName The package name we created the receiver for
281     * @param sessionId The session Id we created the receiver for
282     * @param callback The callback to report success/failure to
283     */
284    private void handleCommitCallback(Intent intent, String packageName, int sessionId,
285            InstallListener callback) {
286        if (Log.isLoggable(TAG, Log.DEBUG)) {
287            Log.d(TAG, "Installation of " + packageName + " finished with extras "
288                    + intent.getExtras());
289        }
290        String statusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
291        int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Integer.MIN_VALUE);
292        if (status == PackageInstaller.STATUS_SUCCESS) {
293            cancelSession(sessionId, packageName);
294            callback.installSucceeded();
295        } else if (status == -1 /*PackageInstaller.STATUS_USER_ACTION_REQUIRED*/) {
296            // TODO - use the constant when the correct/final name is in the SDK
297            // TODO This is unexpected, so we are treating as failure for now
298            cancelSession(sessionId, packageName);
299            callback.installFailed(InstallerConstants.ERROR_INSTALL_USER_ACTION_REQUIRED,
300                    "Unexpected: user action required");
301        } else {
302            cancelSession(sessionId, packageName);
303            int errorCode = getPackageManagerErrorCode(status);
304            Log.e(TAG, "Error " + errorCode + " while installing " + packageName + ": "
305                    + statusMessage);
306            callback.installFailed(errorCode, null);
307        }
308    }
309
310    private int getPackageManagerErrorCode(int status) {
311        // This is a hack: because PackageInstaller now reports error codes
312        // with small positive values, we need to remap them into a space
313        // that is more compatible with the existing package manager error codes.
314        // See https://sites.google.com/a/google.com/universal-store/documentation
315        //       /android-client/download-error-codes
316        int errorCode;
317        if (status == Integer.MIN_VALUE) {
318            errorCode = InstallerConstants.ERROR_INSTALL_MALFORMED_BROADCAST;
319        } else {
320            errorCode = InstallerConstants.ERROR_PACKAGEINSTALLER_BASE - status;
321        }
322        return errorCode;
323    }
324}