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