diff options
author | 2019-12-11 17:45:38 -0700 | |
---|---|---|
committer | 2019-12-17 12:12:57 -0700 | |
commit | eea49d3c8f120205aa58a86ca0f19683ee88ed5c (patch) | |
tree | 7c811b21c37b49dfce8d90d3f553c4380a06540e | |
parent | 16bba13d25da1077ed565bfd4e11acdaf6f5a543 (diff) |
Methods to streamline bulk media operations.
The new storage model being built in Android Q and R means that most
apps will now be limited to read access to most media items on the
device. This change adds a way for developers to request a user to
grant narrow access to specific media items.
This supports operations like requesting write access, trash/untrash,
favorite/unfavorite, and outright delete. Once the user confirms the
action in the dialog, the action is carried out with no further
action needed by the caller.
We transport the set of Uris through a ClipData to pave the way for
shifting to ParceledListSlice in a future release.
Since there's many permutations of strings needed, generate them
with a simple Python script to ensure they stay consistent.
Bug: 141911164
Test: atest --test-mapping packages/apps/MediaProvider
Test: atest CtsAppSecurityHostTestCases:android.appsecurity.cts.ExternalStorageHostTest#testMediaEscalation
Change-Id: I70cda4759b9f0bfdfe397f3ee4dabf42d5bff7d0
-rw-r--r-- | AndroidManifest.xml | 2 | ||||
-rwxr-xr-x | deploy.sh | 2 | ||||
-rw-r--r-- | gen_strings.py | 88 | ||||
-rw-r--r-- | res/color/thumb_more_tint.xml | 21 | ||||
-rw-r--r-- | res/drawable/thumb_clip.xml | 20 | ||||
-rw-r--r-- | res/layout/permission_body.xml | 101 | ||||
-rw-r--r-- | res/values-xlarge/dimens.xml | 12 | ||||
-rw-r--r-- | res/values/dimens.xml | 25 | ||||
-rw-r--r-- | res/values/strings.xml | 151 | ||||
-rw-r--r-- | src/com/android/providers/media/MediaProvider.java | 189 | ||||
-rw-r--r-- | src/com/android/providers/media/PermissionActivity.java | 531 |
11 files changed, 973 insertions, 169 deletions
diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 0e1e04ed2..72ff57892 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -94,7 +94,7 @@ <activity android:name="com.android.providers.media.PermissionActivity" android:theme="@style/PickerDialogTheme" - android:permission="android.permission.WRITE_MEDIA_STORAGE" + android:exported="false" android:excludeFromRecents="true" /> </application> </manifest> @@ -8,4 +8,6 @@ adb remount adb sync adb shell umount /apex/com.android.mediaprovider* adb shell setprop ctl.restart apexd +adb shell rm -rf /system/priv-app/MediaProvider +adb shell rm -rf /system/priv-app/MediaProviderGoogle adb shell start diff --git a/gen_strings.py b/gen_strings.py new file mode 100644 index 000000000..ee0684161 --- /dev/null +++ b/gen_strings.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python +# +# Copyright (C) 2019 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.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.apache.org/licenses/LICENSE-2.0 +# +# 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. + +""" +Helper script to generate tedious strings.xml permutations +""" + +from string import Template + +verbs = ["write","trash","untrash","delete"] +datas = [("audio","audio file"),("video","video"),("image","photo"),("generic","item")] + +print ''' +<!-- ========================= BEGIN AUTO-GENERATED BY gen_strings.py ========================= -->''' + +for verb in verbs: + verblabel = verb + if verb == "write": + verblabel = "change" + + verblabelcaps = verblabel[0].upper() + verblabel[1:] + if verb == "trash": + verblabelcaps = "Move to trash" + if verb == "untrash": + verblabelcaps = "Move out of trash" + + print ''' +<!-- ========================= %s STRINGS ========================= --> +''' % (verb.upper()) + for data, datalabel in datas: + if verb == "trash": + print Template(''' +<!-- Dialog title asking if user will allow $verb permission to the $data item displayed below this string. [CHAR LIMIT=128] --> +<plurals name="permission_${verb}_${data}"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this $datalabel to trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> ${datalabel}s to trash?</item> +</plurals> +''').substitute(vars()).strip("\n") + print Template(''' +<!-- Dialog body text explaining that this $data item will be permanently deleted after the shown duration. [CHAR LIMIT=128] --> +<plurals name="permission_${verb}_${data}_info"> + <item quantity="one">This $datalabel will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> + <item quantity="other">These ${datalabel}s will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> +</plurals> +''').substitute(vars()).strip("\n") + + elif verb == "untrash": + print Template(''' +<!-- Dialog title asking if user will allow $verb permission to the $data item displayed below this string. [CHAR LIMIT=128] --> +<plurals name="permission_${verb}_${data}"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this $datalabel out of trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> ${datalabel}s out of trash?</item> +</plurals> +''').substitute(vars()).strip("\n") + + else: + print Template(''' +<!-- Dialog title asking if user will allow $verb permission to the $data item displayed below this string. [CHAR LIMIT=128] --> +<plurals name="permission_${verb}_${data}"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> $verblabel this $datalabel?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> $verblabel <xliff:g id="count" example="42">^2</xliff:g> ${datalabel}s?</item> +</plurals> +''').substitute(vars()).strip("\n") + + + print Template(''' +<!-- Positive dialog button confirming that $verb permission should be granted. [CHAR LIMIT=32] --> +<string name="permission_${verb}_grant">${verblabelcaps}</string> +<!-- Negative dialog button confirming that $verb permission should not be granted. [CHAR LIMIT=32] --> +<string name="permission_${verb}_deny">Cancel</string> +''').substitute(vars()).strip("\n") + +print ''' +<!-- ========================= END AUTO-GENERATED BY gen_strings.py ========================= --> +''' diff --git a/res/color/thumb_more_tint.xml b/res/color/thumb_more_tint.xml new file mode 100644 index 000000000..3205bf97e --- /dev/null +++ b/res/color/thumb_more_tint.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2019 The Android Open Source Project + + Licensed under the Apache License, Version 2.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.apache.org/licenses/LICENSE-2.0 + + 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. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item + android:color="?android:attr/colorAccent" + android:alpha="0.6" /> +</selector> diff --git a/res/drawable/thumb_clip.xml b/res/drawable/thumb_clip.xml new file mode 100644 index 000000000..0a05ecd16 --- /dev/null +++ b/res/drawable/thumb_clip.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2019 The Android Open Source Project + + Licensed under the Apache License, Version 2.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.apache.org/licenses/LICENSE-2.0 + + 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. +--> + +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <corners android:radius="4dp" /> +</shape> diff --git a/res/layout/permission_body.xml b/res/layout/permission_body.xml new file mode 100644 index 000000000..a4d1250c5 --- /dev/null +++ b/res/layout/permission_body.xml @@ -0,0 +1,101 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2019 The Android Open Source Project + + Licensed under the Apache License, Version 2.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.apache.org/licenses/LICENSE-2.0 + + 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. +--> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="12dp" + android:paddingBottom="12dp" + android:orientation="vertical"> + + <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="?android:attr/listPreferredItemPaddingStart" + android:layout_marginEnd="?android:attr/listPreferredItemPaddingEnd" + android:orientation="horizontal"> + <ImageView + android:id="@+id/thumb1" + android:layout_width="@dimen/permission_thumb_size" + android:layout_height="@dimen/permission_thumb_size" + android:layout_marginEnd="@dimen/permission_thumb_margin" + android:background="@drawable/thumb_clip" + android:scaleType="centerCrop" + android:visibility="gone" /> + <ImageView + android:id="@+id/thumb2" + android:layout_width="@dimen/permission_thumb_size" + android:layout_height="@dimen/permission_thumb_size" + android:layout_marginEnd="@dimen/permission_thumb_margin" + android:background="@drawable/thumb_clip" + android:scaleType="centerCrop" + android:visibility="gone" /> + <ImageView + android:id="@+id/thumb3" + android:layout_width="@dimen/permission_thumb_size" + android:layout_height="@dimen/permission_thumb_size" + android:layout_marginEnd="@dimen/permission_thumb_margin" + android:background="@drawable/thumb_clip" + android:scaleType="centerCrop" + android:visibility="gone" /> + <FrameLayout + android:id="@+id/thumb_more_container" + android:layout_width="@dimen/permission_thumb_size" + android:layout_height="@dimen/permission_thumb_size" + android:visibility="gone"> + <ImageView + android:id="@+id/thumb_more" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@drawable/thumb_clip" + android:scaleType="centerCrop" + android:tint="@color/thumb_more_tint" /> + <TextView + android:id="@+id/thumb_more_text" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center" + android:textColor="?android:attr/textColorPrimaryInverse" /> + </FrameLayout> + </LinearLayout> + + <ImageView + android:id="@+id/thumb_full" + android:layout_width="match_parent" + android:layout_height="200dp" + android:scaleType="centerCrop" + android:visibility="gone" /> + + <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="?android:attr/listPreferredItemPaddingStart" + android:layout_marginEnd="?android:attr/listPreferredItemPaddingEnd" + android:orientation="vertical"> + <TextView + android:id="@+id/list" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="12dp" + android:visibility="gone" /> + <TextView + android:id="@+id/message" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="12dp" + android:visibility="gone" /> + </LinearLayout> +</LinearLayout> diff --git a/res/values-xlarge/dimens.xml b/res/values-xlarge/dimens.xml deleted file mode 100644 index 6931fc895..000000000 --- a/res/values-xlarge/dimens.xml +++ /dev/null @@ -1,12 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- -** -** Copyright 2010 Google Inc. All Rights Reserved. -** ---> - -<resources> - <!-- maximum size for album art. the real size of the albumart will be divided by two - until both width and height are below this value. --> - <dimen name="maximum_thumb_size">500dip</dimen> -</resources> diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 8e43d3ace..840135e28 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -1,14 +1,21 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- -** -** Copyright 2010 Google Inc. All Rights Reserved. -** +<!-- Copyright (C) 2019 The Android Open Source Project + + Licensed under the Apache License, Version 2.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.apache.org/licenses/LICENSE-2.0 + + 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. --> <resources> - <!-- maximum size for album art. the real size of the albumart will be divided by two - until both width and height are below this value. --> - <dimen name="maximum_thumb_size">320dip</dimen> - - <dimen name="default_gap">8dip</dimen> + <dimen name="permission_dialog_width">320dp</dimen> + <dimen name="permission_thumb_size">64dp</dimen> + <dimen name="permission_thumb_margin">6dp</dimen> </resources> diff --git a/res/values/strings.xml b/res/values/strings.xml index f76ce51f5..4309d68d8 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -39,15 +39,152 @@ <!-- Button text that users can click on to request permission before working with a media item. [CHAR LIMIT=32] --> <string name="permission_required_action">Continue</string> - <!-- Prompt asking if user will allow permission to the audio item shown below this string. [CHAR LIMIT=128] --> - <string name="permission_audio">Allow <b><xliff:g id="app_name" example="Gmail">^1</xliff:g></b> to modify or delete this music?</string> - <!-- Prompt asking if user will allow permission to the video item shown below this string. [CHAR LIMIT=128] --> - <string name="permission_video">Allow <b><xliff:g id="app_name" example="Gmail">^1</xliff:g></b> to modify or delete this video?</string> - <!-- Prompt asking if user will allow permission to the image item shown below this string. [CHAR LIMIT=128] --> - <string name="permission_images">Allow <b><xliff:g id="app_name" example="Gmail">^1</xliff:g></b> to modify or delete this image?</string> - <!-- Title for the dialog button to allow a permission grant. [CHAR LIMIT=15] --> <string name="grant_dialog_button_allow">Allow</string> <!-- Title for the dialog button to deny a permission grant. [CHAR LIMIT=15] --> <string name="grant_dialog_button_deny">Deny</string> + + <!-- Text placed over a visual thumbnail indicating that there are more items beyond the number currently displayed on the screen. [CHAR LIMIT=6] --> + <plurals name="permission_more_thumb"> + <item quantity="other">+<xliff:g id="count" example="42">^1</xliff:g></item> + </plurals> + + <!-- Text shown at the end of a list indicating that there are more items beyond the number currently displayed on the screen. [CHAR LIMIT=32] --> + <plurals name="permission_more_text"> + <item quantity="one">Plus <xliff:g id="count" example="42">^1</xliff:g> additional item</item> + <item quantity="other">Plus <xliff:g id="count" example="42">^1</xliff:g> additional items</item> + </plurals> + + <!-- ========================= BEGIN AUTO-GENERATED BY gen_strings.py ========================= --> + + <!-- ========================= WRITE STRINGS ========================= --> + + <!-- Dialog title asking if user will allow write permission to the audio item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_write_audio"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> change this audio file?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> change <xliff:g id="count" example="42">^2</xliff:g> audio files?</item> + </plurals> + <!-- Dialog title asking if user will allow write permission to the video item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_write_video"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> change this video?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> change <xliff:g id="count" example="42">^2</xliff:g> videos?</item> + </plurals> + <!-- Dialog title asking if user will allow write permission to the image item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_write_image"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> change this photo?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> change <xliff:g id="count" example="42">^2</xliff:g> photos?</item> + </plurals> + <!-- Dialog title asking if user will allow write permission to the generic item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_write_generic"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> change this item?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> change <xliff:g id="count" example="42">^2</xliff:g> items?</item> + </plurals> + <!-- Positive dialog button confirming that write permission should be granted. [CHAR LIMIT=32] --> + <string name="permission_write_grant">Change</string> + <!-- Negative dialog button confirming that write permission should not be granted. [CHAR LIMIT=32] --> + <string name="permission_write_deny">Cancel</string> + + <!-- ========================= TRASH STRINGS ========================= --> + + <!-- Dialog title asking if user will allow trash permission to the audio item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_trash_audio"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this audio file to trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> audio files to trash?</item> + </plurals> + <!-- Dialog body text explaining that this audio item will be permanently deleted after the shown duration. [CHAR LIMIT=128] --> + <plurals name="permission_trash_audio_info"> + <item quantity="one">This audio file will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> + <item quantity="other">These audio files will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> + </plurals> + <!-- Dialog title asking if user will allow trash permission to the video item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_trash_video"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this video to trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> videos to trash?</item> + </plurals> + <!-- Dialog body text explaining that this video item will be permanently deleted after the shown duration. [CHAR LIMIT=128] --> + <plurals name="permission_trash_video_info"> + <item quantity="one">This video will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> + <item quantity="other">These videos will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> + </plurals> + <!-- Dialog title asking if user will allow trash permission to the image item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_trash_image"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this photo to trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> photos to trash?</item> + </plurals> + <!-- Dialog body text explaining that this image item will be permanently deleted after the shown duration. [CHAR LIMIT=128] --> + <plurals name="permission_trash_image_info"> + <item quantity="one">This photo will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> + <item quantity="other">These photos will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> + </plurals> + <!-- Dialog title asking if user will allow trash permission to the generic item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_trash_generic"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this item to trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> items to trash?</item> + </plurals> + <!-- Dialog body text explaining that this generic item will be permanently deleted after the shown duration. [CHAR LIMIT=128] --> + <plurals name="permission_trash_generic_info"> + <item quantity="one">This item will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> + <item quantity="other">These items will be permanently deleted after <xliff:g id="duration" example="42">^3</xliff:g> days</item> + </plurals> + <!-- Positive dialog button confirming that trash permission should be granted. [CHAR LIMIT=32] --> + <string name="permission_trash_grant">Move to trash</string> + <!-- Negative dialog button confirming that trash permission should not be granted. [CHAR LIMIT=32] --> + <string name="permission_trash_deny">Cancel</string> + + <!-- ========================= UNTRASH STRINGS ========================= --> + + <!-- Dialog title asking if user will allow untrash permission to the audio item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_untrash_audio"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this audio file out of trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> audio files out of trash?</item> + </plurals> + <!-- Dialog title asking if user will allow untrash permission to the video item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_untrash_video"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this video out of trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> videos out of trash?</item> + </plurals> + <!-- Dialog title asking if user will allow untrash permission to the image item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_untrash_image"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this photo out of trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> photos out of trash?</item> + </plurals> + <!-- Dialog title asking if user will allow untrash permission to the generic item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_untrash_generic"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move this item out of trash?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> move <xliff:g id="count" example="42">^2</xliff:g> items out of trash?</item> + </plurals> + <!-- Positive dialog button confirming that untrash permission should be granted. [CHAR LIMIT=32] --> + <string name="permission_untrash_grant">Move out of trash</string> + <!-- Negative dialog button confirming that untrash permission should not be granted. [CHAR LIMIT=32] --> + <string name="permission_untrash_deny">Cancel</string> + + <!-- ========================= DELETE STRINGS ========================= --> + + <!-- Dialog title asking if user will allow delete permission to the audio item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_delete_audio"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> delete this audio file?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> delete <xliff:g id="count" example="42">^2</xliff:g> audio files?</item> + </plurals> + <!-- Dialog title asking if user will allow delete permission to the video item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_delete_video"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> delete this video?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> delete <xliff:g id="count" example="42">^2</xliff:g> videos?</item> + </plurals> + <!-- Dialog title asking if user will allow delete permission to the image item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_delete_image"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> delete this photo?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> delete <xliff:g id="count" example="42">^2</xliff:g> photos?</item> + </plurals> + <!-- Dialog title asking if user will allow delete permission to the generic item displayed below this string. [CHAR LIMIT=128] --> + <plurals name="permission_delete_generic"> + <item quantity="one">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> delete this item?</item> + <item quantity="other">Let <xliff:g id="app_name" example="Gmail">^1</xliff:g> delete <xliff:g id="count" example="42">^2</xliff:g> items?</item> + </plurals> + <!-- Positive dialog button confirming that delete permission should be granted. [CHAR LIMIT=32] --> + <string name="permission_delete_grant">Delete</string> + <!-- Negative dialog button confirming that delete permission should not be granted. [CHAR LIMIT=32] --> + <string name="permission_delete_deny">Cancel</string> + + <!-- ========================= END AUTO-GENERATED BY gen_strings.py ========================= --> + </resources> diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java index 4bd282ba3..50b9fedf3 100644 --- a/src/com/android/providers/media/MediaProvider.java +++ b/src/com/android/providers/media/MediaProvider.java @@ -59,6 +59,7 @@ import android.app.PendingIntent; import android.app.RecoverableSecurityException; import android.app.RemoteAction; import android.content.BroadcastReceiver; +import android.content.ClipData; import android.content.ClipDescription; import android.content.ContentProvider; import android.content.ContentProviderClient; @@ -3734,12 +3735,92 @@ public class MediaProvider extends ContentProvider { case MediaStore.SUICIDE_CALL: { Log.v(TAG, "Suicide requested!"); android.os.Process.killProcess(android.os.Process.myPid()); + return null; + } + case MediaStore.CREATE_WRITE_REQUEST_CALL: + case MediaStore.CREATE_FAVORITE_REQUEST_CALL: + case MediaStore.CREATE_TRASH_REQUEST_CALL: + case MediaStore.CREATE_DELETE_REQUEST_CALL: { + final PendingIntent pi = createRequest(method, extras); + final Bundle res = new Bundle(); + res.putParcelable(MediaStore.EXTRA_RESULT, pi); + return res; } default: throw new UnsupportedOperationException("Unsupported call: " + method); } } + static List<Uri> collectUris(ClipData clipData) { + final ArrayList<Uri> res = new ArrayList<>(); + for (int i = 0; i < clipData.getItemCount(); i++) { + res.add(clipData.getItemAt(i).getUri()); + } + return res; + } + + /** + * Generate the {@link PendingIntent} for the given grant request. This + * method also sanity checks the incoming arguments for security purposes + * before creating the privileged {@link PendingIntent}. + */ + private @NonNull PendingIntent createRequest(@NonNull String method, @NonNull Bundle extras) { + final ClipData clipData = extras.getParcelable(MediaStore.EXTRA_CLIP_DATA); + final List<Uri> uris = collectUris(clipData); + + final String volumeName = MediaStore.getVolumeName(uris.get(0)); + for (Uri uri : uris) { + // Require that everything is on the same volume + if (!Objects.equals(volumeName, MediaStore.getVolumeName(uri))) { + throw new IllegalArgumentException("All requested items must be on same volume"); + } + + final int match = matchUri(uri, false); + switch (match) { + case IMAGES_MEDIA_ID: + case AUDIO_MEDIA_ID: + case VIDEO_MEDIA_ID: + // Caller is requesting a specific media item by its ID, + // which means it's valid for requests + break; + default: + throw new IllegalArgumentException( + "All requested items must be referenced by specific ID"); + } + } + + // Enforce that limited set of columns can be mutated + final ContentValues values = extras.getParcelable(MediaStore.EXTRA_CONTENT_VALUES); + final List<String> allowedColumns; + switch (method) { + case MediaStore.CREATE_FAVORITE_REQUEST_CALL: + allowedColumns = Arrays.asList( + MediaColumns.IS_FAVORITE); + break; + case MediaStore.CREATE_TRASH_REQUEST_CALL: + allowedColumns = Arrays.asList( + MediaColumns.IS_TRASHED, + MediaColumns.DATE_EXPIRES); + break; + default: + allowedColumns = Arrays.asList(); + break; + } + if (values != null) { + for (String key : values.keySet()) { + if (!allowedColumns.contains(key)) { + throw new IllegalArgumentException("Invalid column " + key); + } + } + } + + final Context context = getContext(); + final Intent intent = new Intent(method, null, context, PermissionActivity.class); + intent.putExtras(extras); + return PendingIntent.getActivity(context, PermissionActivity.REQUEST_CODE, intent, + FLAG_ONE_SHOT | FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE); + } + /** * Execute the given operation for each media item contributed by given * package. The meaning of "contributed" means it won't automatically be @@ -5622,9 +5703,9 @@ public class MediaProvider extends ContentProvider { // Caller has read access, but they wanted to write, and // they'll need to get the user to grant that access final Context context = getContext(); - final PendingIntent intent = PendingIntent.getActivity(context, 42, - new Intent(null, uri, context, PermissionActivity.class), - FLAG_ONE_SHOT | FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE); + final Collection<Uri> uris = Arrays.asList(uri); + final PendingIntent intent = MediaStore + .createWriteRequest(ContentResolver.wrap(this), uris); final Icon icon = getCollectionIcon(uri); final RemoteAction action = new RemoteAction(icon, @@ -5908,62 +5989,62 @@ public class MediaProvider extends ContentProvider { // WARNING: the values of IMAGES_MEDIA, AUDIO_MEDIA, and VIDEO_MEDIA and AUDIO_PLAYLISTS // are stored in the "files" table, so do not renumber them unless you also add // a corresponding database upgrade step for it. - private static final int IMAGES_MEDIA = 1; - private static final int IMAGES_MEDIA_ID = 2; - private static final int IMAGES_MEDIA_ID_THUMBNAIL = 3; - private static final int IMAGES_THUMBNAILS = 4; - private static final int IMAGES_THUMBNAILS_ID = 5; - - private static final int AUDIO_MEDIA = 100; - private static final int AUDIO_MEDIA_ID = 101; - private static final int AUDIO_MEDIA_ID_GENRES = 102; - private static final int AUDIO_MEDIA_ID_GENRES_ID = 103; - private static final int AUDIO_MEDIA_ID_PLAYLISTS = 104; - private static final int AUDIO_MEDIA_ID_PLAYLISTS_ID = 105; - private static final int AUDIO_GENRES = 106; - private static final int AUDIO_GENRES_ID = 107; - private static final int AUDIO_GENRES_ID_MEMBERS = 108; - private static final int AUDIO_GENRES_ALL_MEMBERS = 109; - private static final int AUDIO_PLAYLISTS = 110; - private static final int AUDIO_PLAYLISTS_ID = 111; - private static final int AUDIO_PLAYLISTS_ID_MEMBERS = 112; - private static final int AUDIO_PLAYLISTS_ID_MEMBERS_ID = 113; - private static final int AUDIO_ARTISTS = 114; - private static final int AUDIO_ARTISTS_ID = 115; - private static final int AUDIO_ALBUMS = 116; - private static final int AUDIO_ALBUMS_ID = 117; - private static final int AUDIO_ARTISTS_ID_ALBUMS = 118; - private static final int AUDIO_ALBUMART = 119; - private static final int AUDIO_ALBUMART_ID = 120; - private static final int AUDIO_ALBUMART_FILE_ID = 121; - - private static final int VIDEO_MEDIA = 200; - private static final int VIDEO_MEDIA_ID = 201; - private static final int VIDEO_MEDIA_ID_THUMBNAIL = 202; - private static final int VIDEO_THUMBNAILS = 203; - private static final int VIDEO_THUMBNAILS_ID = 204; - - private static final int VOLUMES = 300; - private static final int VOLUMES_ID = 301; - - private static final int MEDIA_SCANNER = 500; - - private static final int FS_ID = 600; - private static final int VERSION = 601; - - private static final int FILES = 700; - private static final int FILES_ID = 701; + static final int IMAGES_MEDIA = 1; + static final int IMAGES_MEDIA_ID = 2; + static final int IMAGES_MEDIA_ID_THUMBNAIL = 3; + static final int IMAGES_THUMBNAILS = 4; + static final int IMAGES_THUMBNAILS_ID = 5; + + static final int AUDIO_MEDIA = 100; + static final int AUDIO_MEDIA_ID = 101; + static final int AUDIO_MEDIA_ID_GENRES = 102; + static final int AUDIO_MEDIA_ID_GENRES_ID = 103; + static final int AUDIO_MEDIA_ID_PLAYLISTS = 104; + static final int AUDIO_MEDIA_ID_PLAYLISTS_ID = 105; + static final int AUDIO_GENRES = 106; + static final int AUDIO_GENRES_ID = 107; + static final int AUDIO_GENRES_ID_MEMBERS = 108; + static final int AUDIO_GENRES_ALL_MEMBERS = 109; + static final int AUDIO_PLAYLISTS = 110; + static final int AUDIO_PLAYLISTS_ID = 111; + static final int AUDIO_PLAYLISTS_ID_MEMBERS = 112; + static final int AUDIO_PLAYLISTS_ID_MEMBERS_ID = 113; + static final int AUDIO_ARTISTS = 114; + static final int AUDIO_ARTISTS_ID = 115; + static final int AUDIO_ALBUMS = 116; + static final int AUDIO_ALBUMS_ID = 117; + static final int AUDIO_ARTISTS_ID_ALBUMS = 118; + static final int AUDIO_ALBUMART = 119; + static final int AUDIO_ALBUMART_ID = 120; + static final int AUDIO_ALBUMART_FILE_ID = 121; + + static final int VIDEO_MEDIA = 200; + static final int VIDEO_MEDIA_ID = 201; + static final int VIDEO_MEDIA_ID_THUMBNAIL = 202; + static final int VIDEO_THUMBNAILS = 203; + static final int VIDEO_THUMBNAILS_ID = 204; + + static final int VOLUMES = 300; + static final int VOLUMES_ID = 301; + + static final int MEDIA_SCANNER = 500; + + static final int FS_ID = 600; + static final int VERSION = 601; + + static final int FILES = 700; + static final int FILES_ID = 701; // Used only by the MTP implementation - private static final int MTP_OBJECTS = 702; - private static final int MTP_OBJECTS_ID = 703; - private static final int MTP_OBJECT_REFERENCES = 704; + static final int MTP_OBJECTS = 702; + static final int MTP_OBJECTS_ID = 703; + static final int MTP_OBJECT_REFERENCES = 704; // Used only to invoke special logic for directories - private static final int FILES_DIRECTORY = 706; + static final int FILES_DIRECTORY = 706; - private static final int DOWNLOADS = 800; - private static final int DOWNLOADS_ID = 801; + static final int DOWNLOADS = 800; + static final int DOWNLOADS_ID = 801; /** Flag if we're running as {@link MediaStore#AUTHORITY_LEGACY} */ private boolean mLegacyProvider; diff --git a/src/com/android/providers/media/PermissionActivity.java b/src/com/android/providers/media/PermissionActivity.java index f217f79e7..837e1278b 100644 --- a/src/com/android/providers/media/PermissionActivity.java +++ b/src/com/android/providers/media/PermissionActivity.java @@ -16,38 +16,88 @@ package com.android.providers.media; +import static com.android.providers.media.MediaProvider.AUDIO_MEDIA_ID; +import static com.android.providers.media.MediaProvider.IMAGES_MEDIA_ID; import static com.android.providers.media.MediaProvider.TAG; +import static com.android.providers.media.MediaProvider.VIDEO_MEDIA_ID; +import static com.android.providers.media.MediaProvider.collectUris; import android.app.Activity; import android.app.AlertDialog; +import android.content.ContentProviderOperation; import android.content.ContentResolver; +import android.content.ContentValues; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; +import android.graphics.ImageDecoder; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.provider.MediaStore; import android.provider.MediaStore.MediaColumns; import android.text.TextUtils; +import android.text.format.DateUtils; import android.util.Log; import android.util.Size; import android.view.KeyEvent; +import android.view.View; import android.view.WindowManager; -import android.widget.FrameLayout; import android.widget.ImageView; -import android.widget.ImageView.ScaleType; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.providers.media.MediaProvider.LocalUriMatcher; import com.android.providers.media.util.Metrics; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +/** + * Permission dialog that asks for user confirmation before performing a + * specific action, such as granting access for a narrow set of media files to + * the calling app. + * + * @see MediaStore#createWriteRequest + * @see MediaStore#createTrashRequest + * @see MediaStore#createFavoriteRequest + * @see MediaStore#createDeleteRequest + */ public class PermissionActivity extends Activity { + // TODO: narrow metrics to specific verb that was requested + + public static final int REQUEST_CODE = 42; + + private List<Uri> uris; + private ContentValues values; + + private CharSequence label; + private String verb; + private String data; + private String volumeName; + + private static final String VERB_WRITE = "write"; + private static final String VERB_TRASH = "trash"; + private static final String VERB_UNTRASH = "untrash"; + private static final String VERB_FAVORITE = "favorite"; + private static final String VERB_UNFAVORITE = "unfavorite"; + private static final String VERB_DELETE = "delete"; + + private static final String DATA_AUDIO = "audio"; + private static final String DATA_VIDEO = "video"; + private static final String DATA_IMAGE = "image"; + private static final String DATA_GENERIC = "generic"; + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -57,97 +107,146 @@ public class PermissionActivity extends Activity { WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); setFinishOnTouchOutside(false); - final Uri uri; - final CharSequence label; - final int resId; + // All untrusted input values here were validated when generating the + // original PendingIntent try { - uri = getIntent().getData(); - label = getCallingLabel(); - resId = getMessageId(); + uris = collectUris(getIntent().getExtras().getParcelable(MediaStore.EXTRA_CLIP_DATA)); + values = getIntent().getExtras().getParcelable(MediaStore.EXTRA_CONTENT_VALUES); + + label = resolveCallingLabel(); + verb = resolveVerb(); + data = resolveData(); + volumeName = MediaStore.getVolumeName(uris.get(0)); } catch (Exception e) { Log.w(TAG, e); finish(); return; } - final Resources res = getResources(); - final FrameLayout view = new FrameLayout(this); - final int padding = res.getDimensionPixelSize(R.dimen.default_gap); - view.setPadding(padding, padding, padding, padding); - new AsyncTask<Void, Void, Description>() { + // Favorite-related requests are automatically granted for now; we still + // make developers go through this no-op dialog flow to preserve our + // ability to start prompting in the future + switch (verb) { + case VERB_FAVORITE: + case VERB_UNFAVORITE: { + onPositiveAction(null, 0); + return; + } + } + + // Kick off async loading of description to show in dialog + final View bodyView = getLayoutInflater().inflate(R.layout.permission_body, null, false); + new DescriptionTask(bodyView).execute(uris); + + final CharSequence message = resolveMessageText(); + if (!TextUtils.isEmpty(message)) { + final TextView messageView = bodyView.requireViewById(R.id.message); + messageView.setVisibility(View.VISIBLE); + messageView.setText(message); + } + + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(resolveTitleText()); + builder.setPositiveButton(resolvePositiveText(), this::onPositiveAction); + builder.setNegativeButton(resolveNegativeText(), this::onNegativeAction); + builder.setCancelable(false); + builder.setView(bodyView); + + final AlertDialog dialog = builder.show(); + final WindowManager.LayoutParams params = dialog.getWindow().getAttributes(); + params.width = getResources().getDimensionPixelSize(R.dimen.permission_dialog_width); + dialog.getWindow().setAttributes(params); + } + + private void onPositiveAction(DialogInterface dialog, int which) { + new AsyncTask<Void, Void, Void>() { @Override - protected Description doInBackground(Void... params) { + protected Void doInBackground(Void... params) { + Log.d(TAG, "User allowed grant for " + uris); + Metrics.logPermissionGranted(volumeName, + System.currentTimeMillis(), getCallingPackage(), 1); try { - return new Description(PermissionActivity.this, uri); + switch (getIntent().getAction()) { + case MediaStore.CREATE_WRITE_REQUEST_CALL: { + for (Uri uri : uris) { + grantUriPermission(getCallingPackage(), uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + break; + } + case MediaStore.CREATE_TRASH_REQUEST_CALL: + case MediaStore.CREATE_FAVORITE_REQUEST_CALL: { + final ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (Uri uri : uris) { + ops.add(ContentProviderOperation.newUpdate(uri) + .withValues(values) + .withExceptionAllowed(true) + .build()); + } + getContentResolver().applyBatch(MediaStore.AUTHORITY, ops); + break; + } + case MediaStore.CREATE_DELETE_REQUEST_CALL: { + final ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (Uri uri : uris) { + ops.add(ContentProviderOperation.newDelete(uri) + .withExceptionAllowed(true) + .build()); + } + getContentResolver().applyBatch(MediaStore.AUTHORITY, ops); + break; + } + } } catch (Exception e) { Log.w(TAG, e); - finish(); - return null; } + return null; } @Override - protected void onPostExecute(Description result) { - if (result == null) return; - - if (result.thumbnail != null) { - Log.d(TAG, "Found thumbnail " + result.thumbnail.getWidth() + "x" - + result.thumbnail.getHeight()); - - final ImageView child = new ImageView(PermissionActivity.this); - child.setScaleType(ScaleType.CENTER_INSIDE); - child.setImageBitmap(result.thumbnail); - child.setContentDescription(result.contentDescription); - view.addView(child); - } else { - Log.d(TAG, "Found description " + result.contentDescription); - - final TextView child = new TextView(PermissionActivity.this); - child.setText(result.contentDescription); - view.addView(child); - } + protected void onPostExecute(Void result) { + setResult(Activity.RESULT_OK); + finish(); } }.execute(); + } - final AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setMessage(TextUtils.expandTemplate(getText(resId), label)); - builder.setPositiveButton(getString(R.string.grant_dialog_button_allow), - (dialog, which) -> { - Log.d(TAG, "User allowed grant for " + uri); - Metrics.logPermissionGranted(MediaStore.getVolumeName(uri), - System.currentTimeMillis(), getCallingPackage(), 1); - grantUriPermission(getCallingPackage(), uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION | - Intent.FLAG_GRANT_WRITE_URI_PERMISSION | - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - setResult(Activity.RESULT_OK); - finish(); - }); - builder.setNegativeButton(getString(R.string.grant_dialog_button_deny), - (dialog, which) -> { - Log.d(TAG, "User declined grant for " + uri); - Metrics.logPermissionDenied(MediaStore.getVolumeName(uri), - System.currentTimeMillis(), getCallingPackage(), 1); - finish(); - }); - builder.setCancelable(false); - builder.setView(view); - builder.show(); + private void onNegativeAction(DialogInterface dialog, int which) { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + Log.d(TAG, "User declined request for " + uris); + Metrics.logPermissionDenied(volumeName, + System.currentTimeMillis(), getCallingPackage(), 1); + return null; + } + + @Override + protected void onPostExecute(Void result) { + setResult(Activity.RESULT_CANCELED); + finish(); + } + }.execute(); } @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { + public boolean onKeyDown(int keyCode, KeyEvent event) { // Strategy borrowed from PermissionController return keyCode == KeyEvent.KEYCODE_BACK; } @Override - public boolean onKeyUp(int keyCode, KeyEvent event) { + public boolean onKeyUp(int keyCode, KeyEvent event) { // Strategy borrowed from PermissionController return keyCode == KeyEvent.KEYCODE_BACK; } - private CharSequence getCallingLabel() throws NameNotFoundException { + /** + * Resolve a label that represents the remote calling app, typically the + * name of that app. + */ + private @NonNull CharSequence resolveCallingLabel() throws NameNotFoundException { final String callingPackage = getCallingPackage(); if (TextUtils.isEmpty(callingPackage)) { throw new NameNotFoundException("Missing calling package"); @@ -163,42 +262,302 @@ public class PermissionActivity extends Activity { return callingLabel; } - private int getMessageId() throws NameNotFoundException { - final Uri uri = getIntent().getData(); - final String type = uri.getPathSegments().get(1); - switch (type) { - case "audio": return R.string.permission_audio; - case "video": return R.string.permission_video; - case "images": return R.string.permission_images; + private @NonNull String resolveVerb() { + switch (getIntent().getAction()) { + case MediaStore.CREATE_WRITE_REQUEST_CALL: + return VERB_WRITE; + case MediaStore.CREATE_TRASH_REQUEST_CALL: + return (values.getAsInteger(MediaColumns.IS_TRASHED) != 0) + ? VERB_TRASH : VERB_UNTRASH; + case MediaStore.CREATE_FAVORITE_REQUEST_CALL: + return (values.getAsInteger(MediaColumns.IS_FAVORITE) != 0) + ? VERB_FAVORITE : VERB_UNFAVORITE; + case MediaStore.CREATE_DELETE_REQUEST_CALL: + return VERB_DELETE; + default: + throw new IllegalArgumentException("Invalid action: " + getIntent().getAction()); + } + } + + /** + * Resolve what kind of data this permission request is asking about. If the + * requested data is of mixed types, this returns {@link #DATA_GENERIC}. + */ + private @NonNull String resolveData() { + final LocalUriMatcher matcher = new LocalUriMatcher(MediaStore.AUTHORITY); + final int firstMatch = matcher.matchUri(uris.get(0), false); + for (int i = 1; i < uris.size(); i++) { + final int match = matcher.matchUri(uris.get(i), false); + if (match != firstMatch) { + // Any mismatch means we need to use generic strings + return DATA_GENERIC; + } + } + switch (firstMatch) { + case AUDIO_MEDIA_ID: return DATA_AUDIO; + case VIDEO_MEDIA_ID: return DATA_VIDEO; + case IMAGES_MEDIA_ID: return DATA_IMAGE; + default: return DATA_GENERIC; + } + } + + /** + * Resolve the dialog title string to be displayed to the user. All + * arguments have been bound and this string is ready to be displayed. + */ + private @Nullable CharSequence resolveTitleText() { + final String resName = "permission_" + verb + "_" + data; + final int resId = getResources().getIdentifier(resName, "plurals", + getResources().getResourcePackageName(R.string.app_label)); + if (resId != 0) { + final int count = uris.size(); + final CharSequence text = getResources().getQuantityText(resId, count); + return TextUtils.expandTemplate(text, label, String.valueOf(count)); + } else { + // We always need a string to prompt the user with + throw new IllegalStateException("Invalid resource: " + resName); + } + } + + /** + * Resolve the dialog message string to be displayed to the user, if any. + * All arguments have been bound and this string is ready to be displayed. + */ + private @Nullable CharSequence resolveMessageText() { + final String resName = "permission_" + verb + "_" + data + "_info"; + final int resId = getResources().getIdentifier(resName, "plurals", + getResources().getResourcePackageName(R.string.app_label)); + if (resId != 0) { + final int count = uris.size(); + final long durationMillis = (values.getAsLong(MediaColumns.DATE_EXPIRES) * 1000) + - System.currentTimeMillis(); + final long durationDays = (durationMillis + DateUtils.DAY_IN_MILLIS) + / DateUtils.DAY_IN_MILLIS; + final CharSequence text = getResources().getQuantityText(resId, count); + return TextUtils.expandTemplate(text, label, String.valueOf(count), + String.valueOf(durationDays)); + } else { + // Only some actions have a secondary message string; it's okay if + // there isn't one defined + return null; } - throw new NameNotFoundException("Unknown media type " + uri); } + private @NonNull CharSequence resolvePositiveText() { + final String resName = "permission_" + verb + "_grant"; + final int resId = getResources().getIdentifier(resName, "string", + getResources().getResourcePackageName(R.string.app_label)); + return getResources().getText(resId); + } + + private @NonNull CharSequence resolveNegativeText() { + final String resName = "permission_" + verb + "_deny"; + final int resId = getResources().getIdentifier(resName, "string", + getResources().getResourcePackageName(R.string.app_label)); + return getResources().getText(resId); + } + + /** + * Task that will load a set of {@link Description} to be eventually + * displayed in the body of the dialog. + */ + private class DescriptionTask extends AsyncTask<List<Uri>, Void, List<Description>> { + private static final int MAX_THUMBS = 3; + + private View bodyView; + private Resources res; + + public DescriptionTask(@NonNull View bodyView) { + this.bodyView = bodyView; + this.res = bodyView.getContext().getResources(); + } + + @Override + protected List<Description> doInBackground(List<Uri>... params) { + final List<Uri> uris = params[0]; + final List<Description> res = new ArrayList<>(); + + // Default information that we'll load for each item + int loadFlags = Description.LOAD_THUMBNAIL | Description.LOAD_CONTENT_DESCRIPTION; + int neededThumbs = MAX_THUMBS; + + // If we're only asking for single item, load the full image + if (uris.size() == 1) { + loadFlags |= Description.LOAD_FULL; + } + + for (Uri uri : uris) { + try { + final Description desc = new Description(bodyView.getContext(), uri, loadFlags); + res.add(desc); + + // Once we've loaded enough information to bind our UI, we + // can skip loading data for remaining requested items, but + // we still need to create them to show the correct counts + if (desc.isVisual()) { + neededThumbs--; + } + if (neededThumbs == 0) { + loadFlags = 0; + } + } catch (Exception e) { + // Keep rolling forward to try getting enough descriptions + Log.w(TAG, e); + } + } + return res; + } + + @Override + protected void onPostExecute(List<Description> results) { + // Decide how to bind results based on how many are visual + final List<Description> visualResults = results.stream().filter(Description::isVisual) + .collect(Collectors.toList()); + if (results.size() == 1 && visualResults.size() == 1) { + bindAsFull(results.get(0)); + } else if (!visualResults.isEmpty()) { + bindAsThumbs(results, visualResults); + } else { + bindAsText(results); + } + } + + /** + * Bind dialog as a single full-bleed image. + */ + private void bindAsFull(@NonNull Description result) { + final ImageView thumbFull = bodyView.requireViewById(R.id.thumb_full); + result.bindFull(thumbFull); + } + + /** + * Bind dialog as a list of multiple thumbnails. + */ + private void bindAsThumbs(@NonNull List<Description> results, + @NonNull List<Description> visualResults) { + final List<ImageView> thumbs = new ArrayList<>(); + thumbs.add(bodyView.requireViewById(R.id.thumb1)); + thumbs.add(bodyView.requireViewById(R.id.thumb2)); + thumbs.add(bodyView.requireViewById(R.id.thumb3)); + + // We're going to show the "more" tile when we can't display + // everything requested, but we have at least one visual item + final boolean showMore = (visualResults.size() != results.size()) + || (visualResults.size() > MAX_THUMBS); + if (showMore) { + final View thumbMoreContainer = bodyView.requireViewById(R.id.thumb_more_container); + final ImageView thumbMore = bodyView.requireViewById(R.id.thumb_more); + final TextView thumbMoreText = bodyView.requireViewById(R.id.thumb_more_text); + + // Since we only want three tiles displayed maximum, swap out + // the first tile for our "more" tile + thumbs.remove(0); + thumbs.add(thumbMore); + + final int shownCount = Math.min(visualResults.size(), MAX_THUMBS - 1); + final int moreCount = results.size() - shownCount; + final CharSequence moreText = TextUtils.expandTemplate(res.getQuantityText( + R.plurals.permission_more_thumb, moreCount), String.valueOf(moreCount)); + + thumbMoreText.setText(moreText); + thumbMoreContainer.setVisibility(View.VISIBLE); + } + + // Trim off extra thumbnails from the front of our list, so that we + // always bind any "more" item last + while (thumbs.size() > visualResults.size()) { + thumbs.remove(0); + } + + // Finally we can bind all our thumbnails into place + for (int i = 0; i < thumbs.size(); i++) { + final Description desc = visualResults.get(i); + final ImageView imageView = thumbs.get(i); + desc.bindThumbnail(imageView); + } + } + + /** + * Bind dialog as a list of text descriptions, typically when there's no + * visual representation of the items. + */ + private void bindAsText(@NonNull List<Description> results) { + final List<CharSequence> list = new ArrayList<>(); + for (int i = 0; i < results.size(); i++) { + list.add(results.get(i).contentDescription); + + if (list.size() >= MAX_THUMBS && results.size() > list.size()) { + final int moreCount = results.size() - list.size(); + final CharSequence moreText = TextUtils.expandTemplate(res.getQuantityText( + R.plurals.permission_more_text, moreCount), String.valueOf(moreCount)); + list.add(moreText); + break; + } + } + + final TextView text = bodyView.requireViewById(R.id.list); + text.setText(TextUtils.join("\n", list)); + text.setVisibility(View.VISIBLE); + } + } + + /** + * Description of a single media item. + */ private static class Description { - public Bitmap thumbnail; - public CharSequence contentDescription; + public @Nullable CharSequence contentDescription; + public @Nullable Bitmap thumbnail; + public @Nullable Bitmap full; - public Description(Context context, Uri uri) { + public static final int LOAD_CONTENT_DESCRIPTION = 1 << 0; + public static final int LOAD_THUMBNAIL = 1 << 1; + public static final int LOAD_FULL = 1 << 2; + + public Description(Context context, Uri uri, int loadFlags) { final Resources res = context.getResources(); final ContentResolver resolver = context.getContentResolver(); - final Size size = new Size(res.getDisplayMetrics().widthPixels, - res.getDisplayMetrics().widthPixels); try { - thumbnail = resolver.loadThumbnail(uri, size, null); + // Load description first so that we'll always have something + // textual to display in case we have image trouble below + if ((loadFlags & LOAD_CONTENT_DESCRIPTION) != 0) { + try (Cursor c = resolver.query(uri, + new String[] { MediaColumns.DISPLAY_NAME }, null, null)) { + if (c.moveToFirst()) { + contentDescription = c.getString(0); + } + } + } + if ((loadFlags & LOAD_THUMBNAIL) != 0) { + final Size size = new Size(res.getDisplayMetrics().widthPixels, + res.getDisplayMetrics().widthPixels); + thumbnail = resolver.loadThumbnail(uri, size, null); + } + if ((loadFlags & LOAD_FULL) != 0) { + full = ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri)); + } } catch (IOException e) { Log.w(TAG, e); } - try (Cursor c = resolver.query(uri, - new String[] { MediaColumns.DISPLAY_NAME }, null, null)) { - if (c.moveToFirst()) { - contentDescription = c.getString(0); - } - } + } - if (TextUtils.isEmpty(contentDescription)) { - throw new IllegalStateException(); - } + public boolean isVisual() { + return thumbnail != null || full != null; + } + + public void bindThumbnail(ImageView imageView) { + Objects.requireNonNull(thumbnail); + imageView.setImageBitmap(thumbnail); + imageView.setContentDescription(contentDescription); + imageView.setVisibility(View.VISIBLE); + imageView.setClipToOutline(true); + } + + public void bindFull(ImageView imageView) { + Objects.requireNonNull(full); + imageView.setImageBitmap(full); + imageView.setContentDescription(contentDescription); + imageView.setVisibility(View.VISIBLE); } } } |