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 
17 package com.android.documentsui;
18 
19 import static android.os.Environment.isStandardDirectory;
20 import static android.os.Environment.STANDARD_DIRECTORIES;
21 import static android.os.storage.StorageVolume.EXTRA_DIRECTORY_NAME;
22 import static android.os.storage.StorageVolume.EXTRA_STORAGE_VOLUME;
23 
24 import static com.android.documentsui.LocalPreferences.getScopedAccessPermissionStatus;
25 import static com.android.documentsui.LocalPreferences.PERMISSION_ASK;
26 import static com.android.documentsui.LocalPreferences.PERMISSION_ASK_AGAIN;
27 import static com.android.documentsui.LocalPreferences.PERMISSION_NEVER_ASK;
28 import static com.android.documentsui.LocalPreferences.setScopedAccessPermissionStatus;
29 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_ALREADY_DENIED;
30 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_ALREADY_GRANTED;
31 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_DENIED;
32 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_DENIED_AND_PERSIST;
33 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_ERROR;
34 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_GRANTED;
35 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_INVALID_ARGUMENTS;
36 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_INVALID_DIRECTORY;
37 import static com.android.documentsui.Metrics.logInvalidScopedAccessRequest;
38 import static com.android.documentsui.Metrics.logValidScopedAccessRequest;
39 import static com.android.documentsui.Shared.DEBUG;
40 
41 import android.annotation.SuppressLint;
42 import android.app.Activity;
43 import android.app.ActivityManager;
44 import android.app.AlertDialog;
45 import android.app.Dialog;
46 import android.app.DialogFragment;
47 import android.app.FragmentManager;
48 import android.app.FragmentTransaction;
49 import android.content.ContentProviderClient;
50 import android.content.Context;
51 import android.content.DialogInterface;
52 import android.content.DialogInterface.OnClickListener;
53 import android.content.Intent;
54 import android.content.UriPermission;
55 import android.content.pm.PackageManager;
56 import android.content.pm.PackageManager.NameNotFoundException;
57 import android.net.Uri;
58 import android.os.Bundle;
59 import android.os.Parcelable;
60 import android.os.RemoteException;
61 import android.os.UserHandle;
62 import android.os.storage.StorageManager;
63 import android.os.storage.StorageVolume;
64 import android.os.storage.VolumeInfo;
65 import android.provider.DocumentsContract;
66 import android.text.TextUtils;
67 import android.util.Log;
68 import android.view.View;
69 import android.widget.CheckBox;
70 import android.widget.CompoundButton;
71 import android.widget.CompoundButton.OnCheckedChangeListener;
72 import android.widget.TextView;
73 
74 import java.io.File;
75 import java.io.IOException;
76 import java.util.List;
77 
78 /**
79  * Activity responsible for handling {@link Intent#ACTION_OPEN_EXTERNAL_DOCUMENT}.
80  */
81 public class OpenExternalDirectoryActivity extends Activity {
82     private static final String TAG = "OpenExternalDirectory";
83     private static final String FM_TAG = "open_external_directory";
84     private static final String EXTERNAL_STORAGE_AUTH = "com.android.externalstorage.documents";
85     private static final String EXTRA_FILE = "com.android.documentsui.FILE";
86     private static final String EXTRA_APP_LABEL = "com.android.documentsui.APP_LABEL";
87     private static final String EXTRA_VOLUME_LABEL = "com.android.documentsui.VOLUME_LABEL";
88     private static final String EXTRA_VOLUME_UUID = "com.android.documentsui.VOLUME_UUID";
89     private static final String EXTRA_IS_ROOT = "com.android.documentsui.IS_ROOT";
90     private static final String EXTRA_IS_PRIMARY = "com.android.documentsui.IS_PRIMARY";
91     // Special directory name representing the full volume
92     static final String DIRECTORY_ROOT = "ROOT_DIRECTORY";
93 
94     private ContentProviderClient mExternalStorageClient;
95 
96     @Override
onCreate(Bundle savedInstanceState)97     public void onCreate(Bundle savedInstanceState) {
98         super.onCreate(savedInstanceState);
99         if (savedInstanceState != null) {
100             if (DEBUG) Log.d(TAG, "activity.onCreateDialog(): reusing instance");
101             return;
102         }
103 
104         final Intent intent = getIntent();
105         if (intent == null) {
106             if (DEBUG) Log.d(TAG, "missing intent");
107             logInvalidScopedAccessRequest(this, SCOPED_DIRECTORY_ACCESS_INVALID_ARGUMENTS);
108             setResult(RESULT_CANCELED);
109             finish();
110             return;
111         }
112         final Parcelable storageVolume = intent.getParcelableExtra(EXTRA_STORAGE_VOLUME);
113         if (!(storageVolume instanceof StorageVolume)) {
114             if (DEBUG)
115                 Log.d(TAG, "extra " + EXTRA_STORAGE_VOLUME + " is not a StorageVolume: "
116                         + storageVolume);
117             logInvalidScopedAccessRequest(this, SCOPED_DIRECTORY_ACCESS_INVALID_ARGUMENTS);
118             setResult(RESULT_CANCELED);
119             finish();
120             return;
121         }
122         String directoryName = intent.getStringExtra(EXTRA_DIRECTORY_NAME );
123         if (directoryName == null) {
124             directoryName = DIRECTORY_ROOT;
125         }
126         final StorageVolume volume = (StorageVolume) storageVolume;
127         if (getScopedAccessPermissionStatus(getApplicationContext(), getCallingPackage(),
128                 volume.getUuid(), directoryName) == PERMISSION_NEVER_ASK) {
129             logValidScopedAccessRequest(this, directoryName,
130                     SCOPED_DIRECTORY_ACCESS_ALREADY_DENIED);
131             setResult(RESULT_CANCELED);
132             finish();
133             return;
134         }
135 
136         final int userId = UserHandle.myUserId();
137         if (!showFragment(this, userId, volume, directoryName)) {
138             setResult(RESULT_CANCELED);
139             finish();
140             return;
141         }
142     }
143 
144     @Override
onDestroy()145     public void onDestroy() {
146         super.onDestroy();
147         if (mExternalStorageClient != null) {
148             mExternalStorageClient.close();
149         }
150     }
151 
152     /**
153      * Validates the given path (volume + directory) and display the appropriate dialog asking the
154      * user to grant access to it.
155      */
showFragment(OpenExternalDirectoryActivity activity, int userId, StorageVolume storageVolume, String directoryName)156     private static boolean showFragment(OpenExternalDirectoryActivity activity, int userId,
157             StorageVolume storageVolume, String directoryName) {
158         if (DEBUG)
159             Log.d(TAG, "showFragment() for volume " + storageVolume.dump() + ", directory "
160                     + directoryName + ", and user " + userId);
161         final boolean isRoot = directoryName.equals(DIRECTORY_ROOT);
162         final boolean isPrimary = storageVolume.isPrimary();
163 
164         if (isRoot && isPrimary) {
165             if (DEBUG) Log.d(TAG, "root access requested on primary volume");
166             return false;
167         }
168 
169         final File volumeRoot = storageVolume.getPathFile();
170         File file;
171         try {
172             file = isRoot ? volumeRoot : new File(volumeRoot, directoryName).getCanonicalFile();
173         } catch (IOException e) {
174             Log.e(TAG, "Could not get canonical file for volume " + storageVolume.dump()
175                     + " and directory " + directoryName);
176             logInvalidScopedAccessRequest(activity, SCOPED_DIRECTORY_ACCESS_ERROR);
177             return false;
178         }
179         final StorageManager sm =
180                 (StorageManager) activity.getSystemService(Context.STORAGE_SERVICE);
181 
182         final String root, directory;
183         if (isRoot) {
184             root = volumeRoot.getAbsolutePath();
185             directory = ".";
186         } else {
187             root = file.getParent();
188             directory = file.getName();
189             // Verify directory is valid.
190             if (TextUtils.isEmpty(directory) || !isStandardDirectory(directory)) {
191                 if (DEBUG)
192                     Log.d(TAG, "Directory '" + directory + "' is not standard (full path: '"
193                             + file.getAbsolutePath() + "')");
194                 logInvalidScopedAccessRequest(activity, SCOPED_DIRECTORY_ACCESS_INVALID_DIRECTORY);
195                 return false;
196             }
197         }
198 
199         // Gets volume label and converted path.
200         String volumeLabel = null;
201         String volumeUuid = null;
202         final List<VolumeInfo> volumes = sm.getVolumes();
203         if (DEBUG) Log.d(TAG, "Number of volumes: " + volumes.size());
204         File internalRoot = null;
205         boolean found = true;
206         for (VolumeInfo volume : volumes) {
207             if (isRightVolume(volume, root, userId)) {
208                 found = true;
209                 internalRoot = volume.getInternalPathForUser(userId);
210                 // Must convert path before calling getDocIdForFileCreateNewDir()
211                 if (DEBUG) Log.d(TAG, "Converting " + root + " to " + internalRoot);
212                 file = isRoot ? internalRoot : new File(internalRoot, directory);
213                 volumeUuid = storageVolume.getUuid();
214                 volumeLabel = sm.getBestVolumeDescription(volume);
215                 if (TextUtils.isEmpty(volumeLabel)) {
216                     volumeLabel = storageVolume.getDescription(activity);
217                 }
218                 if (TextUtils.isEmpty(volumeLabel)) {
219                     volumeLabel = activity.getString(android.R.string.unknownName);
220                     Log.w(TAG, "No volume description  for " + volume + "; using " + volumeLabel);
221                 }
222                 break;
223             }
224         }
225         if (internalRoot == null) {
226             // Should not happen on normal circumstances, unless app crafted an invalid volume
227             // using reflection or the list of mounted volumes changed.
228             Log.e(TAG, "Didn't find right volume for '" + storageVolume.dump() + "' on " + volumes);
229             return false;
230         }
231 
232         // Checks if the user has granted the permission already.
233         final Intent intent = getIntentForExistingPermission(activity, isRoot, internalRoot, file);
234         if (intent != null) {
235             logValidScopedAccessRequest(activity, directory,
236                     SCOPED_DIRECTORY_ACCESS_ALREADY_GRANTED);
237             activity.setResult(RESULT_OK, intent);
238             activity.finish();
239             return true;
240         }
241 
242         if (!found) {
243             Log.e(TAG, "Could not get volume for " + file);
244             logInvalidScopedAccessRequest(activity, SCOPED_DIRECTORY_ACCESS_ERROR);
245             return false;
246         }
247 
248         // Gets the package label.
249         final String appLabel = getAppLabel(activity);
250         if (appLabel == null) {
251             // Error already logged.
252             return false;
253         }
254 
255         // Sets args that will be retrieve on onCreate()
256         final Bundle args = new Bundle();
257         args.putString(EXTRA_FILE, file.getAbsolutePath());
258         args.putString(EXTRA_VOLUME_LABEL, volumeLabel);
259         args.putString(EXTRA_VOLUME_UUID, volumeUuid);
260         args.putString(EXTRA_APP_LABEL, appLabel);
261         args.putBoolean(EXTRA_IS_ROOT, isRoot);
262         args.putBoolean(EXTRA_IS_PRIMARY, isPrimary);
263 
264         final FragmentManager fm = activity.getFragmentManager();
265         final FragmentTransaction ft = fm.beginTransaction();
266         final OpenExternalDirectoryDialogFragment fragment =
267                 new OpenExternalDirectoryDialogFragment();
268         fragment.setArguments(args);
269         ft.add(fragment, FM_TAG);
270         ft.commitAllowingStateLoss();
271 
272         return true;
273     }
274 
getAppLabel(Activity activity)275     private static String getAppLabel(Activity activity) {
276         final String packageName = activity.getCallingPackage();
277         final PackageManager pm = activity.getPackageManager();
278         try {
279             return pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString();
280         } catch (NameNotFoundException e) {
281             logInvalidScopedAccessRequest(activity, SCOPED_DIRECTORY_ACCESS_ERROR);
282             Log.w(TAG, "Could not get label for package " + packageName);
283             return null;
284         }
285     }
286 
isRightVolume(VolumeInfo volume, String root, int userId)287     private static boolean isRightVolume(VolumeInfo volume, String root, int userId) {
288         final File userPath = volume.getPathForUser(userId);
289         final String path = userPath == null ? null : volume.getPathForUser(userId).getPath();
290         final boolean isMounted = volume.isMountedReadable();
291         if (DEBUG)
292             Log.d(TAG, "Volume: " + volume
293                     + "\n\tuserId: " + userId
294                     + "\n\tuserPath: " + userPath
295                     + "\n\troot: " + root
296                     + "\n\tpath: " + path
297                     + "\n\tisMounted: " + isMounted);
298 
299         return isMounted && root.equals(path);
300     }
301 
getGrantedUriPermission(Context context, ContentProviderClient provider, File file)302     private static Uri getGrantedUriPermission(Context context, ContentProviderClient provider,
303             File file) {
304         // Calls ExternalStorageProvider to get the doc id for the file
305         final Bundle bundle;
306         try {
307             bundle = provider.call("getDocIdForFileCreateNewDir", file.getPath(), null);
308         } catch (RemoteException e) {
309             Log.e(TAG, "Did not get doc id from External Storage provider for " + file, e);
310             logInvalidScopedAccessRequest(context, SCOPED_DIRECTORY_ACCESS_ERROR);
311             return null;
312         }
313         final String docId = bundle == null ? null : bundle.getString("DOC_ID");
314         if (docId == null) {
315             Log.e(TAG, "Did not get doc id from External Storage provider for " + file);
316             logInvalidScopedAccessRequest(context, SCOPED_DIRECTORY_ACCESS_ERROR);
317             return null;
318         }
319         if (DEBUG) Log.d(TAG, "doc id for " + file + ": " + docId);
320 
321         final Uri uri = DocumentsContract.buildTreeDocumentUri(EXTERNAL_STORAGE_AUTH, docId);
322         if (uri == null) {
323             Log.e(TAG, "Could not get URI for doc id " + docId);
324             return null;
325         }
326         if (DEBUG) Log.d(TAG, "URI for " + file + ": " + uri);
327         return uri;
328     }
329 
createGrantedUriPermissionsIntent(Context context, ContentProviderClient provider, File file)330     private static Intent createGrantedUriPermissionsIntent(Context context,
331             ContentProviderClient provider, File file) {
332         final Uri uri = getGrantedUriPermission(context, provider, file);
333         return createGrantedUriPermissionsIntent(uri);
334     }
335 
createGrantedUriPermissionsIntent(Uri uri)336     private static Intent createGrantedUriPermissionsIntent(Uri uri) {
337         final Intent intent = new Intent();
338         intent.setData(uri);
339         intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
340                 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
341                 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
342                 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
343         return intent;
344     }
345 
getIntentForExistingPermission(OpenExternalDirectoryActivity activity, boolean isRoot, File root, File file)346     private static Intent getIntentForExistingPermission(OpenExternalDirectoryActivity activity,
347             boolean isRoot, File root, File file) {
348         final String packageName = activity.getCallingPackage();
349         final ContentProviderClient storageClient = activity.getExternalStorageClient();
350         final Uri grantedUri = getGrantedUriPermission(activity, storageClient, file);
351         final Uri rootUri = root.equals(file) ? grantedUri
352                 : getGrantedUriPermission(activity, storageClient, root);
353 
354         if (DEBUG)
355             Log.d(TAG, "checking if " + packageName + " already has permission for " + grantedUri
356                     + " or its root (" + rootUri + ")");
357         final ActivityManager am =
358                 (ActivityManager) activity.getSystemService(Context.ACTIVITY_SERVICE);
359         for (UriPermission uriPermission : am.getGrantedUriPermissions(packageName).getList()) {
360             final Uri uri = uriPermission.getUri();
361             if (uri == null) {
362                 Log.w(TAG, "null URI for " + uriPermission);
363                 continue;
364             }
365             if (uri.equals(grantedUri) || uri.equals(rootUri)) {
366                 if (DEBUG) Log.d(TAG, packageName + " already has permission: " + uriPermission);
367                 return createGrantedUriPermissionsIntent(grantedUri);
368             }
369         }
370         if (DEBUG) Log.d(TAG, packageName + " does not have permission for " + grantedUri);
371         return null;
372     }
373 
374     public static class OpenExternalDirectoryDialogFragment extends DialogFragment {
375 
376         private File mFile;
377         private String mVolumeUuid;
378         private String mVolumeLabel;
379         private String mAppLabel;
380         private boolean mIsRoot;
381         private boolean mIsPrimary;
382         private CheckBox mDontAskAgain;
383         private OpenExternalDirectoryActivity mActivity;
384         private AlertDialog mDialog;
385 
386         @Override
onCreate(Bundle savedInstanceState)387         public void onCreate(Bundle savedInstanceState) {
388             super.onCreate(savedInstanceState);
389             setRetainInstance(true);
390             final Bundle args = getArguments();
391             if (args != null) {
392                 mFile = new File(args.getString(EXTRA_FILE));
393                 mVolumeUuid = args.getString(EXTRA_VOLUME_UUID);
394                 mVolumeLabel = args.getString(EXTRA_VOLUME_LABEL);
395                 mAppLabel = args.getString(EXTRA_APP_LABEL);
396                 mIsRoot = args.getBoolean(EXTRA_IS_ROOT);
397                 mIsPrimary= args.getBoolean(EXTRA_IS_PRIMARY);
398             }
399             mActivity = (OpenExternalDirectoryActivity) getActivity();
400         }
401 
402         @Override
onDestroyView()403         public void onDestroyView() {
404             // Workaround for https://code.google.com/p/android/issues/detail?id=17423
405             if (mDialog != null && getRetainInstance()) {
406                 mDialog.setDismissMessage(null);
407             }
408             super.onDestroyView();
409         }
410 
411         @Override
onCreateDialog(Bundle savedInstanceState)412         public Dialog onCreateDialog(Bundle savedInstanceState) {
413             if (mDialog != null) {
414                 if (DEBUG) Log.d(TAG, "fragment.onCreateDialog(): reusing dialog");
415                 return mDialog;
416             }
417             if (mActivity != getActivity()) {
418                 // Sanity check.
419                 Log.wtf(TAG, "activity references don't match on onCreateDialog(): mActivity = "
420                         + mActivity + " , getActivity() = " + getActivity());
421                 mActivity = (OpenExternalDirectoryActivity) getActivity();
422             }
423             final String directory = mFile.getName();
424             final String directoryName = mIsRoot ? DIRECTORY_ROOT : directory;
425             final Context context = mActivity.getApplicationContext();
426             final OnClickListener listener = new OnClickListener() {
427 
428                 @Override
429                 public void onClick(DialogInterface dialog, int which) {
430                     Intent intent = null;
431                     if (which == DialogInterface.BUTTON_POSITIVE) {
432                         intent = createGrantedUriPermissionsIntent(mActivity,
433                                 mActivity.getExternalStorageClient(), mFile);
434                     }
435                     if (which == DialogInterface.BUTTON_NEGATIVE || intent == null) {
436                         logValidScopedAccessRequest(mActivity, directoryName,
437                                 SCOPED_DIRECTORY_ACCESS_DENIED);
438                         final boolean checked = mDontAskAgain.isChecked();
439                         if (checked) {
440                             logValidScopedAccessRequest(mActivity, directory,
441                                     SCOPED_DIRECTORY_ACCESS_DENIED_AND_PERSIST);
442                             setScopedAccessPermissionStatus(context, mActivity.getCallingPackage(),
443                                     mVolumeUuid, directoryName, PERMISSION_NEVER_ASK);
444                         } else {
445                             setScopedAccessPermissionStatus(context, mActivity.getCallingPackage(),
446                                     mVolumeUuid, directoryName, PERMISSION_ASK_AGAIN);
447                         }
448                         mActivity.setResult(RESULT_CANCELED);
449                     } else {
450                         logValidScopedAccessRequest(mActivity, directory,
451                                 SCOPED_DIRECTORY_ACCESS_GRANTED);
452                         mActivity.setResult(RESULT_OK, intent);
453                     }
454                     mActivity.finish();
455                 }
456             };
457 
458             @SuppressLint("InflateParams")
459             // It's ok pass null ViewRoot on AlertDialogs.
460             final View view = View.inflate(mActivity, R.layout.dialog_open_scoped_directory, null);
461             final CharSequence message;
462             if (mIsRoot) {
463                 message = TextUtils.expandTemplate(getText(
464                         R.string.open_external_dialog_root_request), mAppLabel, mVolumeLabel);
465             } else {
466                 message = TextUtils.expandTemplate(
467                         getText(mIsPrimary ? R.string.open_external_dialog_request_primary_volume
468                                 : R.string.open_external_dialog_request),
469                                 mAppLabel, directory, mVolumeLabel);
470             }
471             final TextView messageField = (TextView) view.findViewById(R.id.message);
472             messageField.setText(message);
473             mDialog = new AlertDialog.Builder(mActivity, R.style.Theme_AppCompat_Light_Dialog_Alert)
474                     .setView(view)
475                     .setPositiveButton(R.string.allow, listener)
476                     .setNegativeButton(R.string.deny, listener)
477                     .create();
478 
479             mDontAskAgain = (CheckBox) view.findViewById(R.id.do_not_ask_checkbox);
480             if (getScopedAccessPermissionStatus(context, mActivity.getCallingPackage(),
481                     mVolumeUuid, directoryName) == PERMISSION_ASK_AGAIN) {
482                 mDontAskAgain.setVisibility(View.VISIBLE);
483                 mDontAskAgain.setOnCheckedChangeListener(new OnCheckedChangeListener() {
484 
485                     @Override
486                     public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
487                         mDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(!isChecked);
488                     }
489                 });
490             }
491 
492             return mDialog;
493         }
494 
495         @Override
onCancel(DialogInterface dialog)496         public void onCancel(DialogInterface dialog) {
497             super.onCancel(dialog);
498             final Activity activity = getActivity();
499             logValidScopedAccessRequest(activity, mFile.getName(), SCOPED_DIRECTORY_ACCESS_DENIED);
500             activity.setResult(RESULT_CANCELED);
501             activity.finish();
502         }
503     }
504 
getExternalStorageClient()505     private synchronized ContentProviderClient getExternalStorageClient() {
506         if (mExternalStorageClient == null) {
507             mExternalStorageClient =
508                     getContentResolver().acquireContentProviderClient(EXTERNAL_STORAGE_AUTH);
509         }
510         return mExternalStorageClient;
511     }
512 }
513