diff options
Diffstat (limited to 'tests')
62 files changed, 13918 insertions, 287 deletions
diff --git a/tests/Android.bp b/tests/Android.bp index 61a0e8dcb..88a9708d0 100644 --- a/tests/Android.bp +++ b/tests/Android.bp @@ -226,6 +226,12 @@ android_test { "SettingsLibSelectorWithWidgetPreference", "mediaprovider_flags_java_lib", "flag-junit", + "androidx.media3.media3-common", + "androidx.media3.media3-transformer", + "junit", + "android-support-test", + "mockito-target-minus-junit4", + "platform-compat-test-rules", ], certificate: "media", diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml index e785e929d..acd1a21af 100644 --- a/tests/AndroidManifest.xml +++ b/tests/AndroidManifest.xml @@ -22,6 +22,10 @@ <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS" /> <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" /> + <!-- Permissions required for reading and logging compat changes --> + <uses-permission android:name="android.permission.LOG_COMPAT_CHANGE"/> + <uses-permission android:name="android.permission.READ_COMPAT_CHANGE_CONFIG"/> + <uses-permission android:name="com.android.providers.media.permission.BIND_MEDIA_COGNITION_SERVICE"/> <uses-permission @@ -97,6 +101,12 @@ </intent-filter> </provider> + <provider android:name="com.android.providers.media.cloudproviders.SearchProvider" + android:authorities="com.android.providers.media.photopicker.tests.cloud_search_provider" + android:permission="com.android.providers.media.permission.MANAGE_CLOUD_MEDIA_PROVIDERS" + android:exported="true"> + </provider> + <provider android:name="com.android.providers.media.cloudproviders.CloudProviderPrimary" android:authorities="com.android.providers.media.photopicker.tests.cloud_primary" android:permission="com.android.providers.media.permission.MANAGE_CLOUD_MEDIA_PROVIDERS" @@ -129,6 +139,12 @@ android:exported="true"> </provider> + <provider android:name="com.android.providers.media.photopickersearch.CloudMediaProviderSearch" + android:permission="com.android.providers.media.permission.MANAGE_CLOUD_MEDIA_PROVIDERS" + android:authorities="com.android.providers.media.photopicker.tests.cloud_provider_for_search_client" + android:exported="true"> + </provider> + <service android:name= "com.android.providers.media.stableuris.job.StableUriIdleMaintenanceService" diff --git a/tests/client/src/com/android/providers/media/client/DownloadProviderTest.java b/tests/client/src/com/android/providers/media/client/DownloadProviderTest.java index 3deb2593e..ec83e196e 100644 --- a/tests/client/src/com/android/providers/media/client/DownloadProviderTest.java +++ b/tests/client/src/com/android/providers/media/client/DownloadProviderTest.java @@ -65,6 +65,16 @@ public class DownloadProviderTest { deletePublicVolumes(); } + @Test + public void canCreateOtherPackageExternalFilesDir() { + try { + // Verifies that downloadProvider can create files dir for other packages. + createOtherPackageExternalFilesDir(); + } catch (Exception e) { + throw new UnsupportedOperationException( + "Unable to create files dir: \n" + e.getMessage()); + } + } @Test public void testCanReadWriteOtherAppPrivateFiles() throws Exception { @@ -104,11 +114,11 @@ public class DownloadProviderTest { for (String dir: otherPackageDirsOnSameVolume) { otherPackageDirs.add(new File(dir)); - final String otherPackageExternalFilesDir = dir + "/files"; - executeShellCommand("mkdir -p " + otherPackageExternalFilesDir + " -m 2770"); + File otherPackageExternalFilesDir = new File(dir, "/files"); + otherPackageExternalFilesDir.mkdirs(); // Need to wait for the directory to be created, as the rest of the test depends on // the dir to be created. A race condition can cause the test to be flaky. - pollForDirectoryToBeCreated(new File(otherPackageExternalFilesDir)); + pollForDirectoryToBeCreated(otherPackageExternalFilesDir); } } return otherPackageDirs; diff --git a/tests/hostsidetests/photopicker/TEST_MAPPING b/tests/hostsidetests/photopicker/TEST_MAPPING index 2dfcf6c61..7a8de2095 100644 --- a/tests/hostsidetests/photopicker/TEST_MAPPING +++ b/tests/hostsidetests/photopicker/TEST_MAPPING @@ -1,7 +1,7 @@ { - "postsubmit": [ + "presubmit": [ { "name": "PhotoPickerHostTestCases" } ] -}
\ No newline at end of file +} diff --git a/tests/hostsidetests/photopicker/src/android/tests/photopicker/CloudProviderHostSideTest.kt b/tests/hostsidetests/photopicker/src/android/tests/photopicker/CloudProviderHostSideTest.kt index 7dad80ea7..61d54b2a3 100644 --- a/tests/hostsidetests/photopicker/src/android/tests/photopicker/CloudProviderHostSideTest.kt +++ b/tests/hostsidetests/photopicker/src/android/tests/photopicker/CloudProviderHostSideTest.kt @@ -39,6 +39,8 @@ import org.junit.runner.RunWith class CloudProviderHostSideTest : IDeviceTest { private lateinit var mDevice: ITestDevice + private var mInitialCloudProvider: String? = null + companion object { /** The package name of the test APK. */ private const val TEST_PACKAGE = "com.android.photopicker.testcloudmediaproviderapp" @@ -68,6 +70,15 @@ class CloudProviderHostSideTest : IDeviceTest { fun setUp() { // ensure the test APK is enabled before each test by setting it explicitly. mDevice.executeShellCommand(COMMAND_ENABLE_TEST_APK) + // find the initial cloud provider to be reset at the end of the test execution. + mInitialCloudProvider = null + val result: String = mDevice.executeShellCommand(COMMAND_GET_CLOUD_PROVIDER) + val regex = Regex("get_cloud_provider_result=(.*?)}]") + val matchResult = regex.find(result) + val initialCloudProvider = matchResult?.groupValues?.get(1) + if (initialCloudProvider != "null") { + mInitialCloudProvider = initialCloudProvider + } } /** @@ -87,8 +98,8 @@ class CloudProviderHostSideTest : IDeviceTest { // Add the test package authority to the allowlist for cloud providers. mDevice.executeShellCommand( - "device_config put mediaprovider allowed_cloud_providers " - + "\"$TEST_CLOUD_PROVIDER_AUTHORITY\"" + "device_config put mediaprovider allowed_cloud_providers " + + "\"$TEST_CLOUD_PROVIDER_AUTHORITY\"" ) // Set the test cloud provider as the current provider. @@ -126,8 +137,10 @@ class CloudProviderHostSideTest : IDeviceTest { COMMAND_GET_CLOUD_PROVIDER ) assertWithMessage("Unexpected cloud provider, expected : null") - .that(resultForGetCloudProvider - .contains("{get_cloud_provider_result=null}")) + .that( + resultForGetCloudProvider + .contains("{get_cloud_provider_result=null}") + ) .isTrue() isCloudProviderReset = true break // Condition met, exit the loop @@ -144,5 +157,20 @@ class CloudProviderHostSideTest : IDeviceTest { @Throws(Exception::class) fun tearDown() { mDevice.executeShellCommand(COMMAND_ENABLE_TEST_APK) + + // reset initial cloud provider. + val setCloudProvider: String + if (mInitialCloudProvider != null) { + setCloudProvider = " content call --uri content://media --method set_cloud_provider" + + " --extra cloud_provider:s:$mInitialCloudProvider" + } else { + // set to null if no cloud provider was set earlier. + setCloudProvider = + " content call --uri content://media --method set_cloud_provider" + } + mDevice.executeShellCommand(setCloudProvider) + + // To enable syncs after test is completed + mDevice.executeShellCommand("device_config set_sync_disabled_for_tests none") } }
\ No newline at end of file diff --git a/tests/res/raw/android_mime_types b/tests/res/raw/android_mime_types new file mode 100644 index 000000000..d8a2ce8f5 --- /dev/null +++ b/tests/res/raw/android_mime_types @@ -0,0 +1,166 @@ + +############################################################################### +# +# Android-specific MIME type <-> extension mappings +# +# Each line below defines a mapping from one MIME type to the first of the +# listed extensions, and from listed extension back to the MIME type. +# A mapping overrides any previous mapping _from_ that same MIME type or +# extension (put() semantics), unless that MIME type / extension is prefixed with '?' +# (putIfAbsent() semantics). +# +# +############################################################################### +# +# EXAMPLES +# +# A line of the form: +# +# ?mime ext1 ?ext2 ext3 +# +# affects the current mappings along the lines of the following pseudo code: +# +# mimeToExt.putIfAbsent("mime", "ext1"); +# extToMime.put("ext1", "mime"); +# extToMime.putIfAbsent("ext2", "mime"); +# extToMime.put("ext3", "mime"); +# +# The line: +# +# ?text/plain txt +# +# leaves any earlier mapping for "text/plain" untouched, or maps that MIME type +# to the file extension ".txt" if there is no earlier mapping. The line also +# sets the mapping from file extension ".txt" to be the MIME type "text/plain", +# regardless of whether a previous mapping existed. +# +############################################################################### + + +# File extensions that Android wants to override to point to the given MIME type. +# +# After processing a line of the form: +# ?<mimeType> <extension1> <extension2> +# If <mimeType> was not already mapped to an extension then it will be +# mapped to <extension1>. +# <extension1> and <extension2> are mapped (or remapped) to <mimeType>. + +?application/epub+zip epub +?application/lrc lrc +?application/pkix-cert cer +?application/rss+xml rss +?application/sdp sdp +?application/smil+xml smil +?application/ttml+xml ttml dfxp +?application/vnd.android.haptics.vibration+xml ahv +?application/vnd.android.ota ota +?application/vnd.apple.mpegurl m3u8 +?application/vnd.apple.pkpass pkpass +?application/vnd.apple.pkpasses pkpasses +?application/vnd.ms-pki.stl stl +?application/vnd.ms-powerpoint pot +?application/vnd.ms-wpl wpl +?application/vnd.stardivision.impress sdp +?application/vnd.stardivision.writer vor +?application/vnd.youtube.yt yt +?application/x-android-drm-fl fl +?application/x-flac flac +?application/x-font pcf +?application/x-mobipocket-ebook prc mobi +?application/x-mpegurl m3u m3u8 +?application/x-pem-file pem +?application/x-pkcs12 p12 pfx +?application/x-subrip srt +?application/x-webarchive webarchive +?application/x-webarchive-xml webarchivexml +?application/x-x509-server-cert crt +?application/x-x509-user-cert crt + +?audio/3gpp 3ga 3gpp +?audio/aac-adts aac +?audio/ac3 ac3 a52 +?audio/amr amr +?audio/x-gsm gsm +?audio/imelody imy +?audio/midi rtttl xmf +?audio/mobile-xmf mxmf +?audio/mp4 m4a m4b m4p f4a f4b f4p +?audio/mpegurl m3u +?audio/sp-midi smf +?audio/x-matroska mka +?audio/x-pn-realaudio ra +?audio/x-mpeg mp3 +?audio/mp3 mp3 + +?image/bmp bmp +?image/gif gif +?image/heic heic +?image/heic-sequence heics +?image/heif heif hif +?image/heif-sequence heifs +?image/avif avif +?image/ico cur +?image/webp webp +?image/x-adobe-dng dng +?image/x-fuji-raf raf +?image/x-icon ico +?image/x-jg art +?image/x-nikon-nrw nrw +?image/x-panasonic-rw2 rw2 +?image/x-pentax-pef pef +?image/x-samsung-srw srw +?image/x-sony-arw arw + +?text/comma-separated-values csv +?text/plain diff po +?text/rtf rtf +?text/text phps +?text/xml xml +?text/x-vcard vcf + +?video/3gpp2 3gpp2 3gp2 3g2 +?video/3gpp 3gpp 3gp +?video/avi avi +?video/m4v m4v +?video/mp4 m4v f4v mp4v mpeg4 +?video/mp2p mpeg +?video/mp2t m2ts mts +?video/mp2ts ts +?video/vnd.youtube.yt yt +?video/x-webex wrf + +# Optional additions that should not override any previous mapping. + +?application/x-wifi-config ?xml +?multipart/related mht + +# Special cases where Android has a strong opinion about mappings, so we +# define them very last and make them override in both directions (no "?"). +# +# Lines here are of the form: +# <mimeType> <extension1> <extension2> ... +# +# After processing each line, +# <mimeType> is mapped to <extension1> +# <extension1>, <extension2>, ... are all mapped to <mimeType> +# This overrides any mappings for this <mimeType> / for these extensions +# that may have been defined earlier. + +application/pgp-signature pgp +application/x-x509-ca-cert crt der +audio/aac aac adts adt +audio/basic snd +audio/flac flac +audio/midi rtx +audio/mpeg mp3 mp2 mp1 mpa m4a m4r +audio/x-mpegurl m3u m3u8 +image/jpeg jpg +image/x-ms-bmp bmp +image/x-photoshop psd +text/plain txt +text/x-c++hdr hpp +text/x-c++src cpp +video/3gpp 3gpp +video/mpeg mpeg mpeg2 mpv2 mp2v m2v m2t mpeg1 mpv1 mp1v m1v +video/quicktime mov +video/x-matroska mkv
\ No newline at end of file diff --git a/tests/res/raw/corrupted_mime_types b/tests/res/raw/corrupted_mime_types new file mode 100644 index 000000000..dc34a542a --- /dev/null +++ b/tests/res/raw/corrupted_mime_types @@ -0,0 +1,2320 @@ +############################################################################### +# +# Media (MIME) types and the extensions that represent them. +# +# The format of this file is a media type on the left and zero or more +# filename extensions on the right. Programs using this file will map +# files ending with those extensions to the associated type. +# +# This file is part of the "media-types" package. Please report a bug using +# the "reportbug" command of the "reportbug" package if you would like new +# types or extensions to be added. +# +# The reason that all types are managed by the media-types package instead +# allowing individual packages to install types in much the same way as they +# add entries in to the mailcap file is so these types can be referenced by +# other programs (such as a web server) even if the specific support package +# for that type is not installed. +# +# Users can add their own types if they wish by creating a ".mime.types" +# file in their home directory. Definitions included there will take +# precedence over those listed here. +# +############################################################################### + +application/1d-interleaved-parityfec +application/3gpdash-qoe-report+xml +application/3gpp-ims+xml +application/3gppHal+json +application/3gppHalForms+json +application/A2L a2l +application/ace+cbor +application/ace+json +application/activemessage +application/activity+json +application/aif+cbor +application/aif+json +application/alto-cdni+json +application/alto-cdnifilter+json +application/alto-costmap+json +application/alto-costmapfilter+json +application/alto-directory+json +application/alto-endpointcost+json +application/alto-endpointcostparams+json +application/alto-endpointprop+json +application/alto-endpointpropparams+json +application/alto-error+json +application/alto-networkmap+json +application/alto-networkmapfilter+json +application/alto-propmap+json +application/alto-propmapparams+json +application/alto-updatestreamcontrol+json +application/alto-updatestreamparams+json +application/AML aml +application/andrew-inset ez +application/annodex anx +application/applefile +application/at+jwt +application/ATF atf +application/ATFX atfx +application/atom+xml atom +application/atomcat+xml atomcat +application/atomdeleted+xml atomdeleted +application/atomicmail +application/atomserv+xml atomsrv +application/atomsvc+xml atomsvc +application/atsc-dwd+xml dwd +application/atsc-dynamic-event-message +application/atsc-held+xml held +application/atsc-rdt+json +application/atsc-rsat+xml rsat +application/ATXML atxml +application/auth-policy+xml apxml +application/automationml-aml+xml +application/automationml-amlx+zip amlx +application/bacnet-xdd+zip xdd +application/batch-SMTP +application/bbolin lin +application/beep+xml +application/calendar+json +application/calendar+xml xcs +application/call-completion +application/CALS-1840 +application/captive+json +application/cbor cbor +application/cbor-seq +application/cccex c3ex +application/ccmp+xml ccmp +application/ccxml+xml ccxml +application/cda+xml +application/CDFX+XML cdfx +application/cdmi-capability cdmia +application/cdmi-container cdmic +application/cdmi-domain cdmid +application/cdmi-object cdmio +application/cdmi-queue cdmiq +application/cdni +application/CEA cea +application/cea-2018+xml +application/cellml+xml cellml cml +application/cfw +application/city+json +application/clr 1clr +application/clue+xml +application/clue_info+xml clue +application/cms cmsc +application/cnrp+xml +application/coap-group+json +application/coap-payload +application/commonground +application/concise-problem-details+cbor +application/conference-info+xml +application/cose +application/cose-key +application/cose-key-set +application/cose-x509 +application/cpl+xml cpl +application/csrattrs csrattrs +application/csta+xml +application/CSTAdata+xml +application/csvm+json +application/cu-seeme cu +application/cwl cwl +application/cwl+json cwl.json +application/cwt +application/cybercash +application/dash+xml mpd +application/dash-patch+xml +application/dashdelta mpdd +application/davmount+xml davmount +application/dca-rft +application/DCD dcd +application/dec-dx +application/dialog-info+xml +application/dicom dcm +application/dicom+json +application/dicom+xml +application/DII dii +application/DIT dit +application/dns +application/dns+json +application/dns-message +application/dots+cbor +application/dpop+jwt +application/dskpp+xml xmls +application/dsptype tsp +application/dssc+der dssc +application/dssc+xml xdssc +application/dvcs dvc +application/EDI-consent +application/EDI-X12 +application/EDIFACT +application/efi efi +application/elm+json +application/elm+xml +application/EmergencyCallData.cap+xml +application/EmergencyCallData.Comment+xml +application/EmergencyCallData.Control+xml +application/EmergencyCallData.DeviceInfo+xml +application/EmergencyCallData.eCall.MSD +application/EmergencyCallData.LegacyESN+json +application/EmergencyCallData.ProviderInfo+xml +application/EmergencyCallData.ServiceInfo+xml +application/EmergencyCallData.SubscriberInfo+xml +application/EmergencyCallData.VEDS+xml +application/emma+xml emma +application/emotionml+xml emotionml +application/encaprtp +application/epp+xml +application/epub+zip epub +application/eshop +application/example +application/exi exi +application/expect-ct-report+json +application/express exp +application/fastinfoset finf +application/fastsoap +application/fdf fdf +application/fdt+xml fdt +application/fhir+json +application/fhir+xml +application/fits +application/flexfec +application/font-tdpfr pfr +application/framework-attributes+xml +application/futuresplash spl +application/geo+json geojson +application/geo+json-seq +application/geopackage+sqlite3 gpkg +application/geoxacml+xml +application/gltf-buffer glbin glbuf +application/gml+xml gml +application/gzip gz +application/H224 +application/held+xml +application/hl7v2+xml +application/hta hta +application/http +application/hyperstudio stk +application/ibe-key-request+xml +application/ibe-pkg-reply+xml +application/ibe-pp-data +application/iges +application/im-iscomposing+xml +application/index +application/index.cmd +application/index.obj +application/index.response +application/index.vnd +application/inkml+xml ink inkml +application/IOTP +application/ipfix ipfix +application/ipp +application/ISUP +application/its+xml its +application/java-archive jar +application/java-serialized-object ser +application/java-vm class +application/jf2feed+json +application/jose +application/jose+json +application/jrd+json jrd +application/jscalendar+json +application/json json +application/json-patch+json json-patch +application/json-seq +application/jwk+json +application/jwk-set+json +application/jwt +application/kpml-request+xml +application/kpml-response+xml +application/ld+json jsonld +application/lgr+xml lgr +application/link-format wlnk +application/linkset +application/linkset+json +application/load-control+xml +application/logout+jwt +application/lost+xml lostxml +application/lostsync+xml lostsyncxml +application/lpf+zip lpf +application/LXF lxf +application/m3g m3g +application/mac-binhex40 hqx +application/mac-compactpro cpt +application/macwriteii +application/mads+xml mads +application/manifest+json webmanifest +application/marc mrc +application/marcxml+xml mrcx +application/mathematica ma mb +application/mathml+xml mml +application/mathml-content+xml +application/mathml-presentation+xml +application/mbms-associated-procedure-description+xml +application/mbms-deregister+xml +application/mbms-envelope+xml +application/mbms-msk+xml +application/mbms-msk-response+xml +application/mbms-protection-description+xml +application/mbms-reception-report+xml +application/mbms-register+xml +application/mbms-register-response+xml +application/mbms-schedule+xml +application/mbms-user-service-description+xml +application/mbox mbox +application/media-policy-dataset+xml +application/mediaservercontrol+xml +application/media_control+xml +application/merge-patch+json +application/metalink4+xml meta4 +application/mets+xml mets +application/MF4 mf4 +application/mikey +application/mipc +application/missing-blocks+cbor-seq +application/mmt-aei+xml maei +application/mmt-usd+xml musd +application/mods+xml mods +application/moss-keys +application/moss-signature +application/mosskey-data +application/mosskey-request +application/mp21 m21 mp21 +application/mp4 +application/mpeg4-generic +application/mpeg4-iod +application/mpeg4-iod-xmt +application/mrb-consumer+xml +application/mrb-publish+xml +application/msaccess mdb +application/msc-ivr+xml +application/msc-mixer+xml +application/msword doc +application/mud+json +application/multipart-core +application/mxf mxf +application/n-quads nq +application/n-triples nt +application/nasdata +application/news-checkgroups +application/news-groupinfo +application/news-transmission +application/nlsml+xml +application/node +application/nss +application/oauth-authz-req+jwt +application/oblivious-dns-message +application/ocsp-request orq +application/ocsp-response ors +application/octet-stream bin deploy msu msp +application/ODA oda +application/odm+xml +application/ODX odx +application/oebps-package+xml opf +application/ogg ogx +application/ohttp-keys +application/onenote one onetoc2 onetmp onepkg +application/opc-nodeset+xml +application/oscore +application/oxps oxps +application/p21 p21 stpnc 210 ifc +application/p21+zip +application/p2p-overlay+xml relo +application/parityfec +application/passport +application/patch-ops-error+xml +application/pdf pdf +application/PDX pdx +application/pem-certificate-chain pem +application/pgp-encrypted pgp +application/pgp-keys asc key +application/pgp-signature sig +application/pics-rules prf +application/pidf+xml +application/pidf-diff+xml +application/pkcs10 p10 +application/pkcs12 p12 pfx +application/pkcs7-mime p7m p7c p7z +application/pkcs7-signature p7s +application/pkcs8 p8 +application/pkcs8-encrypted p8e +application/pkix-attr-cert ac +application/pkix-cert cer +application/pkix-crl crl +application/pkix-pkipath pkipath +application/pkixcmp pki +application/pls+xml +application/poc-settings+xml +application/postscript ps ai eps epsi epsf eps2 eps3 +application/ppsp-tracker+json +application/problem+json +application/problem+xml +application/provenance+xml provx +application/prs.alvestrand.titrax-sheet +application/prs.cww cw cww +application/prs.cyn +application/prs.hpub+zip hpub +application/prs.implied-document+xml +application/prs.implied-executable +application/prs.implied-structure +application/prs.nprend rnd rct +application/prs.plucker +application/prs.rdf-xml-crypt rdf-crypt +application/prs.xsf+xml xsf +application/pskc+xml pskcxml +application/pvd+json +application/QSIG +application/raptorfec +application/rdap+json +application/rdf+xml rdf +application/reginfo+xml rif +application/relax-ng-compact-syntax rnc +application/reputon+json +application/resource-lists+xml rl +application/resource-lists-diff+xml rld +application/rfc+xml rfcxml +application/riscos +application/rlmi+xml +application/rls-services+xml rs +application/route-apd+xml rapd +application/route-s-tsid+xml sls +application/route-usd+xml rusd +application/rpki-checklist +application/rpki-ghostbusters gbr +application/rpki-manifest mft +application/rpki-publication +application/rpki-roa roa +application/rpki-updown +application/rtf rtf +application/rtploopback +application/rtx +application/samlassertion+xml +application/samlmetadata+xml +application/sarif+json sarif sarif.json +application/sarif-external-properties+json sarif-external-properties sarif-external-properties.json +application/sbe +application/sbml+xml +application/scaip+xml +application/scim+json scim +application/scvp-cv-request scq +application/scvp-cv-response scs +application/scvp-vp-request spq +application/scvp-vp-response spp +application/sdp sdp +application/secevent+jwt +application/senml+cbor senmlc +application/senml+json senml +application/senml+xml senmlx +application/senml-etch+cbor senml-etchc +application/senml-etch+json senml-etchj +application/senml-exi senmle +application/sensml+cbor sensmlc +application/sensml+json sensml +application/sensml+xml sensmlx +application/sensml-exi sensmle +application/sep+xml +application/sep-exi +application/session-info +application/set-payment +application/set-payment-initiation +application/set-registration +application/set-registration-initiation +application/SGML +application/sgml-open-catalog soc +application/shf+xml shf +application/sieve siv sieve +application/simple-filter+xml cl +application/simple-message-summary +application/simpleSymbolContainer +application/sipc +application/slate +application/smil+xml smil smi sml +application/smpte336m +application/soap+fastinfoset +application/soap+xml +application/sparql-query rq +application/sparql-results+xml srx +application/spdx+json spdx.json +application/spirits-event+xml +application/sql sql +application/srgs gram +application/srgs+xml grxml +application/sru+xml sru +application/ssml+xml ssml +application/stix+json stix +application/swid+cbor coswid +application/swid+xml swidtag +application/tamp-apex-update tau +application/tamp-apex-update-confirm auc +application/tamp-community-update tcu +application/tamp-community-update-confirm cuc +application/tamp-error ter +application/tamp-sequence-adjust tsa +application/tamp-sequence-adjust-confirm sac +application/tamp-status-query +application/tamp-status-response +application/tamp-update tur +application/tamp-update-confirm tuc +application/taxii+json +application/td+json jsontd +application/tei+xml tei teiCorpus odd +application/TETRA_ISI +application/thraud+xml tfi +application/timestamp-query tsq +application/timestamp-reply tsr +application/timestamped-data tsd +application/tlsrpt+gzip +application/tlsrpt+json +application/tm+json tm.jsonld tm.json jsontm +application/tnauthlist +application/token-introspection+jwt +application/trickle-ice-sdpfrag +application/trig trig +application/ttml+xml ttml +application/tve-trigger +application/tzif +application/tzif-leap +application/ulpfec +application/urc-grpsheet+xml gsheet +application/urc-ressheet+xml rsheet +application/urc-targetdesc+xml td +application/urc-uisocketdesc+xml uis +application/vcard+json +application/vcard+xml +application/vemmi +application/vnd.1000minds.decision-model+xml 1km +application/vnd.1ob ob +application/vnd.3gpp-prose+xml +application/vnd.3gpp-prose-pc3a+xml +application/vnd.3gpp-prose-pc3ach+xml +application/vnd.3gpp-prose-pc3ch+xml +application/vnd.3gpp-prose-pc8+xml +application/vnd.3gpp-v2x-local-service-information +application/vnd.3gpp.5gnas +application/vnd.3gpp.access-transfer-events+xml +application/vnd.3gpp.bsf+xml +application/vnd.3gpp.crs+xml +application/vnd.3gpp.current-location-discovery+xml +application/vnd.3gpp.GMOP+xml +application/vnd.3gpp.gtpc +application/vnd.3gpp.interworking-data +application/vnd.3gpp.lpp +application/vnd.3gpp.mc-signalling-ear +application/vnd.3gpp.mcdata-affiliation-command+xml +application/vnd.3gpp.mcdata-info+xml +application/vnd.3gpp.mcdata-msgstore-ctrl-request+xml +application/vnd.3gpp.mcdata-payload +application/vnd.3gpp.mcdata-regroup+xml +application/vnd.3gpp.mcdata-service-config+xml +application/vnd.3gpp.mcdata-signalling +application/vnd.3gpp.mcdata-ue-config+xml +application/vnd.3gpp.mcdata-user-profile+xml +application/vnd.3gpp.mcptt-affiliation-command+xml +application/vnd.3gpp.mcptt-floor-request+xml +application/vnd.3gpp.mcptt-info+xml +application/vnd.3gpp.mcptt-location-info+xml +application/vnd.3gpp.mcptt-mbms-usage-info+xml +application/vnd.3gpp.mcptt-regroup+xml +application/vnd.3gpp.mcptt-service-config+xml +application/vnd.3gpp.mcptt-signed+xml +application/vnd.3gpp.mcptt-ue-config+xml +application/vnd.3gpp.mcptt-ue-init-config+xml +application/vnd.3gpp.mcptt-user-profile+xml +application/vnd.3gpp.mcvideo-affiliation-command+xml +application/vnd.3gpp.mcvideo-info+xml +application/vnd.3gpp.mcvideo-location-info+xml +application/vnd.3gpp.mcvideo-mbms-usage-info+xml +application/vnd.3gpp.mcvideo-regroup+xml +application/vnd.3gpp.mcvideo-service-config+xml +application/vnd.3gpp.mcvideo-transmission-request+xml +application/vnd.3gpp.mcvideo-ue-config+xml +application/vnd.3gpp.mcvideo-user-profile+xml +application/vnd.3gpp.mid-call+xml +application/vnd.3gpp.ngap +application/vnd.3gpp.pfcp +application/vnd.3gpp.pic-bw-large plb +application/vnd.3gpp.pic-bw-small psb +application/vnd.3gpp.pic-bw-var pvb +application/vnd.3gpp.s1ap +application/vnd.3gpp.seal-group-doc+xml +application/vnd.3gpp.seal-info+xml +application/vnd.3gpp.seal-location-info+xml +application/vnd.3gpp.seal-mbms-usage-info+xml +application/vnd.3gpp.seal-network-QoS-management-info+xml +application/vnd.3gpp.seal-ue-config-info+xml +application/vnd.3gpp.seal-unicast-info+xml +application/vnd.3gpp.seal-user-profile-info+xml +application/vnd.3gpp.sms +application/vnd.3gpp.sms+xml +application/vnd.3gpp.srvcc-ext+xml +application/vnd.3gpp.SRVCC-info+xml +application/vnd.3gpp.state-and-event-info+xml +application/vnd.3gpp.ussd+xml +application/vnd.3gpp.v2x +application/vnd.3gpp.vae-info+xml +application/vnd.3gpp2.bcmcsinfo+xml +application/vnd.3gpp2.sms sms +application/vnd.3gpp2.tcap tcap +application/vnd.3lightssoftware.imagescal imgcal +application/vnd.3M.Post-it-Notes pwn +application/vnd.accpac.simply.aso aso +application/vnd.accpac.simply.imp imp +application/vnd.acm.addressxfer+json +application/vnd.acucobol acu +application/vnd.acucorp atc acutc +application/vnd.adobe.flash.movie swf +application/vnd.adobe.formscentral.fcdt fcdt +application/vnd.adobe.fxp fxp fxpl +application/vnd.adobe.partial-upload +application/vnd.adobe.xdp+xml xdp +application/vnd.aether.imp +application/vnd.afpc.afplinedata +application/vnd.afpc.afplinedata-pagedef +application/vnd.afpc.cmoca-cmresource +application/vnd.afpc.foca-charset +application/vnd.afpc.foca-codedfont +application/vnd.afpc.foca-codepage +application/vnd.afpc.modca list3820 listafp afp pseg3820 +application/vnd.afpc.modca-formdef +application/vnd.afpc.modca-mediummap +application/vnd.afpc.modca-objectcontainer +application/vnd.afpc.modca-overlay ovl +application/vnd.afpc.modca-pagesegment psg +application/vnd.age age +application/vnd.ah-barcode +application/vnd.ahead.space ahead +application/vnd.airzip.filesecure.azf azf +application/vnd.airzip.filesecure.azs azs +application/vnd.amadeus+json +application/vnd.amazon.mobi8-ebook azw3 +application/vnd.americandynamics.acc acc +application/vnd.amiga.ami ami +application/vnd.amundsen.maze+xml +application/vnd.android.ota ota +application/vnd.android.package-archive apk +application/vnd.anki apkg +application/vnd.anser-web-certificate-issue-initiation cii +application/vnd.anser-web-funds-transfer-initiation fti +application/vnd.antix.game-component +application/vnd.apache.arrow.file arrow +application/vnd.apache.arrow.stream arrows +application/vnd.apache.thrift.binary +application/vnd.apache.thrift.compact +application/vnd.apache.thrift.json +application/vnd.apexlang apexlang apex +application/vnd.api+json +application/vnd.aplextor.warrp+json +application/vnd.apothekende.reservation+json +application/vnd.apple.installer+xml dist distz pkg mpkg +application/vnd.apple.keynote keynote +application/vnd.apple.mpegurl m3u8 +application/vnd.apple.numbers numbers +application/vnd.apple.pages pages +application/vnd.aristanetworks.swi swi +application/vnd.artisan+json artisan +application/vnd.artsquare +application/vnd.astraea-software.iota iota +application/vnd.audiograph aep +application/vnd.autopackage package +application/vnd.avalon+json +application/vnd.avistar+xml +application/vnd.balsamiq.bmml+xml bmml +application/vnd.balsamiq.bmpr bmpr +application/vnd.banana-accounting ac2 +application/vnd.bbf.usp.error +application/vnd.bbf.usp.msg +application/vnd.bbf.usp.msg+json +application/vnd.bekitzur-stech+json +application/vnd.belightsoft.lhzd+zip lhzd +application/vnd.belightsoft.lhzl+zip lhzl +application/vnd.bint.med-content +application/vnd.biopax.rdf+xml +application/vnd.blink-idb-value-wrapper +application/vnd.blueice.multipass mpm +application/vnd.bluetooth.ep.oob ep +application/vnd.bluetooth.le.oob le +application/vnd.bmi bmi +application/vnd.bpf +application/vnd.bpf3 +application/vnd.businessobjects rep +application/vnd.byu.uapi+json +application/vnd.cab-jscript +application/vnd.canon-cpdl +application/vnd.canon-lips +application/vnd.capasystems-pg+json +application/vnd.cendio.thinlinc.clientconf tlclient +application/vnd.century-systems.tcp_stream +application/vnd.chemdraw+xml cdxml +application/vnd.chess-pgn pgn +application/vnd.chipnuts.karaoke-mmd mmd +application/vnd.ciedi +application/vnd.cinderella cdy +application/vnd.cirpack.isdn-ext +application/vnd.citationstyles.style+xml csl +application/vnd.claymore cla +application/vnd.cloanto.rp9 rp9 +application/vnd.clonk.c4group c4g c4d c4f c4p c4u +application/vnd.cluetrust.cartomobile-config c11amc +application/vnd.cluetrust.cartomobile-config-pkg c11amz +application/vnd.cncf.helm.chart.content.v1.tar+gzip +application/vnd.cncf.helm.chart.provenance.v1.prov +application/vnd.cncf.helm.config.v1+json +application/vnd.coffeescript coffee +application/vnd.collabio.xodocuments.document xodt +application/vnd.collabio.xodocuments.document-template xott +application/vnd.collabio.xodocuments.presentation xodp +application/vnd.collabio.xodocuments.presentation-template xotp +application/vnd.collabio.xodocuments.spreadsheet xods +application/vnd.collabio.xodocuments.spreadsheet-template xots +application/vnd.collection+json +application/vnd.collection.doc+json +application/vnd.collection.next+json +application/vnd.comicbook+zip cbz +application/vnd.comicbook-rar cbr +application/vnd.commerce-battelle icf icd ic0 ic1 ic2 ic3 ic4 ic5 ic6 ic7 ic8 +application/vnd.commonspace csp cst +application/vnd.contact.cmsg cdbcmsg +application/vnd.coreos.ignition+json ign ignition +application/vnd.cosmocaller cmc +application/vnd.crick.clicker clkx +application/vnd.crick.clicker.keyboard clkk +application/vnd.crick.clicker.palette clkp +application/vnd.crick.clicker.template clkt +application/vnd.crick.clicker.wordbank clkw +application/vnd.criticaltools.wbs+xml wbs +application/vnd.cryptii.pipe+json +application/vnd.crypto-shade-file ssvc +application/vnd.cryptomator.encrypted c9r c9s +application/vnd.cryptomator.vault cryptomator +application/vnd.ctc-posml pml +application/vnd.ctct.ws+xml +application/vnd.cups-pdf +application/vnd.cups-postscript +application/vnd.cups-ppd ppd +application/vnd.cups-raster +application/vnd.cups-raw +application/vnd.curl +application/vnd.cyan.dean.root+xml +application/vnd.cybank +application/vnd.cyclonedx+json +application/vnd.cyclonedx+xml +application/vnd.d2l.coursepackage1p0+zip +application/vnd.d3m-dataset +application/vnd.d3m-problem +application/vnd.dart dart +application/vnd.data-vision.rdz rdz +application/vnd.datalog dl +application/vnd.datapackage+json +application/vnd.dataresource+json +application/vnd.dbf dbf +application/vnd.debian.binary-package deb ddeb udeb +application/vnd.dece.data uvf uvvf uvd uvvd +application/vnd.dece.ttml+xml uvt uvvt +application/vnd.dece.unspecified uvx uvvx +application/vnd.dece.zip uvz uvvz +application/vnd.denovo.fcselayout-link fe_launch +application/vnd.desmume.movie dsm +application/vnd.dir-bi.plate-dl-nosuffix +application/vnd.dm.delegation+xml +application/vnd.dna dna +application/vnd.document+json docjson +application/vnd.dolby.mobile.1 +application/vnd.dolby.mobile.2 +application/vnd.doremir.scorecloud-binary-document scld +application/vnd.dpgraph dpg mwc dpgraph +application/vnd.dreamfactory dfac +application/vnd.drive+json +application/vnd.dtg.local +application/vnd.dtg.local.flash fla +application/vnd.dtg.local.html +application/vnd.dvb.ait ait +application/vnd.dvb.dvbisl+xml +application/vnd.dvb.dvbj +application/vnd.dvb.esgcontainer +application/vnd.dvb.ipdcdftnotifaccess +application/vnd.dvb.ipdcesgaccess +application/vnd.dvb.ipdcesgaccess2 +application/vnd.dvb.ipdcesgpdd +application/vnd.dvb.ipdcroaming +application/vnd.dvb.iptv.alfec-base +application/vnd.dvb.iptv.alfec-enhancement +application/vnd.dvb.notif-aggregate-root+xml +application/vnd.dvb.notif-container+xml +application/vnd.dvb.notif-generic+xml +application/vnd.dvb.notif-ia-msglist+xml +application/vnd.dvb.notif-ia-registration-request+xml +application/vnd.dvb.notif-ia-registration-response+xml +application/vnd.dvb.notif-init+xml +application/vnd.dvb.pfr +application/vnd.dvb.service svc +application/vnd.dxr +application/vnd.dynageo geo +application/vnd.dzr dzr +application/vnd.easykaraoke.cdgdownload +application/vnd.ecdis-update +application/vnd.ecip.rlp +application/vnd.eclipse.ditto+json +application/vnd.ecowin.chart mag +application/vnd.ecowin.filerequest +application/vnd.ecowin.fileupdate +application/vnd.ecowin.series +application/vnd.ecowin.seriesrequest +application/vnd.ecowin.seriesupdate +application/vnd.efi.img +application/vnd.efi.iso +application/vnd.eln+zip ELN +application/vnd.emclient.accessrequest+xml +application/vnd.enliven nml +application/vnd.enphase.envoy +application/vnd.eprints.data+xml +application/vnd.epson.esf esf +application/vnd.epson.msf msf +application/vnd.epson.quickanime qam +application/vnd.epson.salt slt +application/vnd.epson.ssf ssf +application/vnd.ericsson.quickcall qcall qca +application/vnd.espass-espass+zip espass +application/vnd.eszigno3+xml es3 et3 +application/vnd.etsi.aoc+xml +application/vnd.etsi.asic-e+zip asice sce +application/vnd.etsi.asic-s+zip asics +application/vnd.etsi.cug+xml +application/vnd.etsi.iptvcommand+xml +application/vnd.etsi.iptvdiscovery+xml +application/vnd.etsi.iptvprofile+xml +application/vnd.etsi.iptvsad-bc+xml +application/vnd.etsi.iptvsad-cod+xml +application/vnd.etsi.iptvsad-npvr+xml +application/vnd.etsi.iptvservice+xml +application/vnd.etsi.iptvsync+xml +application/vnd.etsi.iptvueprofile+xml +application/vnd.etsi.mcid+xml +application/vnd.etsi.mheg5 +application/vnd.etsi.overload-control-policy-dataset+xml +application/vnd.etsi.pstn+xml +application/vnd.etsi.sci+xml +application/vnd.etsi.simservs+xml +application/vnd.etsi.timestamp-token tst +application/vnd.etsi.tsl+xml +application/vnd.etsi.tsl.der +application/vnd.eu.kasparian.car+json carjson +application/vnd.eudora.data +application/vnd.evolv.ecig.profile ecigprofile +application/vnd.evolv.ecig.settings ecig +application/vnd.evolv.ecig.theme ecigtheme +application/vnd.exstream-empower+zip mpw +application/vnd.exstream-package pub +application/vnd.ezpix-album ez2 +application/vnd.ezpix-package ez3 +application/vnd.f-secure.mobile +application/vnd.familysearch.gedcom+zip gdz +application/vnd.fastcopy-disk-image dim +application/vnd.fdsn.mseed msd mseed +application/vnd.fdsn.seed seed dataless +application/vnd.ffsns +application/vnd.ficlab.flb+zip flb +application/vnd.filmit.zfc zfc +application/vnd.fints +application/vnd.firemonkeys.cloudcell +application/vnd.FloGraphIt gph +application/vnd.fluxtime.clip ftc +application/vnd.font-fontforge-sfd sfd +application/vnd.framemaker fm +application/vnd.freelog.comic +application/vnd.fsc.weblaunch fsc +application/vnd.fujifilm.fb.docuworks +application/vnd.fujifilm.fb.docuworks.binder +application/vnd.fujifilm.fb.docuworks.container +application/vnd.fujifilm.fb.jfi+xml +application/vnd.fujitsu.oasys oas +application/vnd.fujitsu.oasys2 oa2 +application/vnd.fujitsu.oasys3 oa3 +application/vnd.fujitsu.oasysgp fg5 +application/vnd.fujitsu.oasysprs bh2 +application/vnd.fujixerox.ART-EX +application/vnd.fujixerox.ART4 +application/vnd.fujixerox.ddd ddd +application/vnd.fujixerox.HBPL +application/vnd.fut-misnet +application/vnd.futoin+cbor +application/vnd.futoin+json +application/vnd.fuzzysheet fzs +application/vnd.genomatix.tuxedo txd +application/vnd.genozip genozip +application/vnd.gentics.grd+json grd +application/vnd.gentoo.catmetadata+xml +application/vnd.gentoo.ebuild ebuild +application/vnd.gentoo.eclass eclass +application/vnd.gentoo.gpkg gpkg.tar +application/vnd.gentoo.manifest +application/vnd.gentoo.pkgmetadata+xml +application/vnd.gentoo.xpak xpak +application/vnd.geogebra.file ggb +application/vnd.geogebra.slides ggs +application/vnd.geogebra.tool ggt +application/vnd.geometry-explorer gex gre +application/vnd.geonext gxt +application/vnd.geoplan g2w +application/vnd.geospace g3w +application/vnd.gerber +application/vnd.globalplatform.card-content-mgt +application/vnd.globalplatform.card-content-mgt-response +application/vnd.gnu.taler.exchange+json +application/vnd.gnu.taler.merchant+json +application/vnd.google-earth.kml+xml kml +application/vnd.google-earth.kmz kmz +application/vnd.gov.sk.e-form+xml +application/vnd.gov.sk.e-form+zip +application/vnd.gov.sk.xmldatacontainer+xml +application/vnd.gpxsee.map+xml +application/vnd.grafeq gqf gqs +application/vnd.gridmp +application/vnd.groove-account gac +application/vnd.groove-help ghf +application/vnd.groove-identity-message gim +application/vnd.groove-injector grv +application/vnd.groove-tool-message gtm +application/vnd.groove-tool-template tpl +application/vnd.groove-vcard vcg +application/vnd.hal+json +application/vnd.hal+xml hal +application/vnd.HandHeld-Entertainment+xml zmm +application/vnd.hbci hbci hbc kom upa pkd bpd +application/vnd.hc+json +application/vnd.hcl-bireports +application/vnd.hdt hdt +application/vnd.heroku+json +application/vnd.hhe.lesson-player les +application/vnd.hp-HPGL hpgl +application/vnd.hp-hpid hpi hpid +application/vnd.hp-hps hps +application/vnd.hp-jlyt jlt +application/vnd.hp-PCL pcl +application/vnd.hp-PCLXL +application/vnd.hsl hsl +application/vnd.httphone +application/vnd.hydrostatix.sof-data sfd-hdstx +application/vnd.hyper+json +application/vnd.hyper-item+json +application/vnd.hyperdrive+json +application/vnd.hzn-3d-crossword +application/vnd.ibm.electronic-media emm +application/vnd.ibm.MiniPay mpy +application/vnd.ibm.rights-management irm +application/vnd.ibm.secure-container sc +application/vnd.iccprofile icc icm +application/vnd.ieee.1905 1905.1 +application/vnd.igloader igl +application/vnd.imagemeter.folder+zip imf +application/vnd.imagemeter.image+zip imi +application/vnd.immervision-ivp ivp +application/vnd.immervision-ivu ivu +application/vnd.ims.imsccv1p1 imscc +application/vnd.ims.imsccv1p2 +application/vnd.ims.imsccv1p3 +application/vnd.ims.lis.v2.result+json +application/vnd.ims.lti.v2.toolconsumerprofile+json +application/vnd.ims.lti.v2.toolproxy+json +application/vnd.ims.lti.v2.toolproxy.id+json +application/vnd.ims.lti.v2.toolsettings+json +application/vnd.ims.lti.v2.toolsettings.simple+json +application/vnd.informedcontrol.rms+xml +application/vnd.infotech.project +application/vnd.infotech.project+xml +application/vnd.innopath.wamp.notification +application/vnd.insors.igm igm +application/vnd.intercon.formnet xpw xpx +application/vnd.intergeo i2g +application/vnd.intertrust.digibox +application/vnd.intertrust.nncp +application/vnd.intu.qbo qbo +application/vnd.intu.qfx qfx +application/vnd.ipfs.ipns-record ipns-record +application/vnd.ipld.car car +application/vnd.ipld.dag-cbor +application/vnd.ipld.dag-json +application/vnd.ipld.raw +application/vnd.iptc.g2.catalogitem+xml +application/vnd.iptc.g2.conceptitem+xml +application/vnd.iptc.g2.knowledgeitem+xml +application/vnd.iptc.g2.newsitem+xml +application/vnd.iptc.g2.newsmessage+xml +application/vnd.iptc.g2.packageitem+xml +application/vnd.iptc.g2.planningitem+xml +application/vnd.ipunplugged.rcprofile rcprofile +application/vnd.irepository.package+xml irp +application/vnd.is-xpr xpr +application/vnd.isac.fcs fcs +application/vnd.iso11783-10+zip +application/vnd.jam jam +application/vnd.japannet-directory-service +application/vnd.japannet-jpnstore-wakeup +application/vnd.japannet-payment-wakeup +application/vnd.japannet-registration +application/vnd.japannet-registration-wakeup +application/vnd.japannet-setstore-wakeup +application/vnd.japannet-verification +application/vnd.japannet-verification-wakeup +application/vnd.jcp.javame.midlet-rms rms +application/vnd.jisp jisp +application/vnd.joost.joda-archive joda +application/vnd.jsk.isdn-ngn +application/vnd.kahootz ktz ktr +application/vnd.kde.karbon karbon +application/vnd.kde.kchart chrt +application/vnd.kde.kformula kfo +application/vnd.kde.kivio flw +application/vnd.kde.kontour kon +application/vnd.kde.kpresenter kpr kpt +application/vnd.kde.kspread ksp +application/vnd.kde.kword kwd kwt +application/vnd.kenameaapp htke +application/vnd.kidspiration kia +application/vnd.Kinar kne knp sdf +application/vnd.koan skp skd skm skt +application/vnd.kodak-descriptor sse +application/vnd.las las +application/vnd.las.las+json lasjson +application/vnd.las.las+xml lasxml +application/vnd.laszip +application/vnd.leap+json +application/vnd.liberty-request+xml +application/vnd.llamagraphics.life-balance.desktop lbd +application/vnd.llamagraphics.life-balance.exchange+xml lbe +application/vnd.logipipe.circuit+zip lcs lca +application/vnd.loom loom +application/vnd.lotus-1-2-3 123 wk4 wk3 wk1 +application/vnd.lotus-approach apr vew +application/vnd.lotus-freelance prz pre +application/vnd.lotus-notes nsf ntf ndl ns4 ns3 ns2 nsh nsg +application/vnd.lotus-organizer or3 or2 org +application/vnd.lotus-screencam scm +application/vnd.lotus-wordpro lwp sam +application/vnd.macports.portpkg portpkg +application/vnd.mapbox-vector-tile mvt +application/vnd.marlin.drm.actiontoken+xml +application/vnd.marlin.drm.conftoken+xml +application/vnd.marlin.drm.license+xml +application/vnd.marlin.drm.mdcf mdc +application/vnd.mason+json +application/vnd.maxar.archive.3tz+zip 3tz +application/vnd.maxmind.maxmind-db mmdb +application/vnd.mcd mcd +application/vnd.mdl mdl +application/vnd.mdl-mbsdf mbsdf +application/vnd.medcalcdata mc1 +application/vnd.mediastation.cdkey cdkey +application/vnd.medicalholodeck.recordxr rxt +application/vnd.meridian-slingshot +application/vnd.MFER mwf +application/vnd.mfmp mfm +application/vnd.micro+json +application/vnd.micrografx.flo flo +application/vnd.micrografx.igx igx +application/vnd.microsoft.portable-executable +application/vnd.microsoft.windows.thumbnail-cache +application/vnd.miele+json +application/vnd.mif mif +application/vnd.minisoft-hp3000-save +application/vnd.mitsubishi.misty-guard.trustweb +application/vnd.Mobius.DAF daf +application/vnd.Mobius.DIS dis +application/vnd.Mobius.MBK mbk +application/vnd.Mobius.MQY mqy +application/vnd.Mobius.MSL msl +application/vnd.Mobius.PLC plc +application/vnd.Mobius.TXF txf +application/vnd.modl modl +application/vnd.mophun.application mpn +application/vnd.mophun.certificate mpc +application/vnd.motorola.flexsuite +application/vnd.motorola.flexsuite.adsi +application/vnd.motorola.flexsuite.fis +application/vnd.motorola.flexsuite.gotap +application/vnd.motorola.flexsuite.kmr +application/vnd.motorola.flexsuite.ttc +application/vnd.motorola.flexsuite.wem +application/vnd.motorola.iprm +application/vnd.mozilla.xul+xml xul +application/vnd.ms-3mfdocument 3mf +application/vnd.ms-artgalry cil +application/vnd.ms-asf asf +application/vnd.ms-cab-compressed cab +application/vnd.ms-excel xls xlm xla xlc xlt xlw +application/vnd.ms-excel.addin.macroEnabled.12 xlam +application/vnd.ms-excel.sheet.binary.macroEnabled.12 xlsb +application/vnd.ms-excel.sheet.macroEnabled.12 xlsm +application/vnd.ms-excel.template.macroEnabled.12 xltm +application/vnd.ms-fontobject eot +application/vnd.ms-htmlhelp chm +application/vnd.ms-ims ims +application/vnd.ms-lrm lrm +application/vnd.ms-office.activeX+xml +application/vnd.ms-officetheme thmx +application/vnd.ms-pki.seccat cat +application/vnd.ms-playready.initiator+xml +application/vnd.ms-powerpoint ppt pps +application/vnd.ms-powerpoint.addin.macroEnabled.12 ppam +application/vnd.ms-powerpoint.presentation.macroEnabled.12 pptm +application/vnd.ms-powerpoint.slide.macroEnabled.12 sldm +application/vnd.ms-powerpoint.slideshow.macroEnabled.12 ppsm +application/vnd.ms-powerpoint.template.macroEnabled.12 potm +application/vnd.ms-PrintDeviceCapabilities+xml +application/vnd.ms-PrintSchemaTicket+xml +application/vnd.ms-project mpp mpt +application/vnd.ms-tnef tnef tnf +application/vnd.ms-windows.devicepairing +application/vnd.ms-windows.nwprinting.oob +application/vnd.ms-windows.printerpairing +application/vnd.ms-windows.wsd.oob +application/vnd.ms-wmdrm.lic-chlg-req +application/vnd.ms-wmdrm.lic-resp +application/vnd.ms-wmdrm.meter-chlg-req +application/vnd.ms-wmdrm.meter-resp +application/vnd.ms-word.document.macroEnabled.12 docm +application/vnd.ms-word.template.macroEnabled.12 dotm +application/vnd.ms-works wcm wdb wks wps +application/vnd.ms-wpl wpl +application/vnd.ms-xpsdocument xps +application/vnd.msa-disk-image msa +application/vnd.mseq mseq +application/vnd.msign +application/vnd.multiad.creator crtr +application/vnd.multiad.creator.cif cif +application/vnd.music-niff +application/vnd.musician mus +application/vnd.muvee.style msty +application/vnd.mynfc taglet +application/vnd.nacamar.ybrid+json +application/vnd.ncd.control +application/vnd.ncd.reference +application/vnd.nearst.inv+json +application/vnd.nebumind.line nebul line +application/vnd.nervana entity request bkm kcm +application/vnd.netfpx +application/vnd.neurolanguage.nlu nlu +application/vnd.nimn nimn +application/vnd.nintendo.nitro.rom nds +application/vnd.nintendo.snes.rom sfc smc +application/vnd.nitf nitf +application/vnd.noblenet-directory nnd +application/vnd.noblenet-sealer nns +application/vnd.noblenet-web nnw +application/vnd.nokia.catalogs +application/vnd.nokia.conml+wbxml +application/vnd.nokia.conml+xml +application/vnd.nokia.iptv.config+xml +application/vnd.nokia.iSDS-radio-presets +application/vnd.nokia.landmark+wbxml +application/vnd.nokia.landmark+xml +application/vnd.nokia.landmarkcollection+xml +application/vnd.nokia.n-gage.ac+xml +application/vnd.nokia.n-gage.data ngdat +application/vnd.nokia.ncd +application/vnd.nokia.pcd+wbxml +application/vnd.nokia.pcd+xml +application/vnd.nokia.radio-preset rpst +application/vnd.nokia.radio-presets rpss +application/vnd.novadigm.EDM edm +application/vnd.novadigm.EDX edx +application/vnd.novadigm.EXT ext +application/vnd.ntt-local.content-share +application/vnd.ntt-local.file-transfer +application/vnd.ntt-local.ogw_remote-access +application/vnd.ntt-local.sip-ta_remote +application/vnd.ntt-local.sip-ta_tcp_stream +application/vnd.oasis.opendocument.base odb +application/vnd.oasis.opendocument.chart odc +application/vnd.oasis.opendocument.chart-template otc +application/vnd.oasis.opendocument.formula odf +application/vnd.oasis.opendocument.formula-template +application/vnd.oasis.opendocument.graphics odg +application/vnd.oasis.opendocument.graphics-template otg +application/vnd.oasis.opendocument.image odi +application/vnd.oasis.opendocument.image-template oti +application/vnd.oasis.opendocument.presentation odp +application/vnd.oasis.opendocument.presentation-template otp +application/vnd.oasis.opendocument.spreadsheet ods +application/vnd.oasis.opendocument.spreadsheet-template ots +application/vnd.oasis.opendocument.text odt +application/vnd.oasis.opendocument.text-master odm +application/vnd.oasis.opendocument.text-master-template otm +application/vnd.oasis.opendocument.text-template ott +application/vnd.oasis.opendocument.text-web oth +application/vnd.obn +application/vnd.ocf+cbor +application/vnd.oci.image.manifest.v1+json +application/vnd.oftn.l10n+json +application/vnd.oipf.contentaccessdownload+xml +application/vnd.oipf.contentaccessstreaming+xml +application/vnd.oipf.cspg-hexbinary +application/vnd.oipf.dae.svg+xml +application/vnd.oipf.dae.xhtml+xml +application/vnd.oipf.mippvcontrolmessage+xml +application/vnd.oipf.pae.gem +application/vnd.oipf.spdiscovery+xml +application/vnd.oipf.spdlist+xml +application/vnd.oipf.ueprofile+xml +application/vnd.oipf.userprofile+xml +application/vnd.olpc-sugar xo +application/vnd.oma-scws-config +application/vnd.oma-scws-http-request +application/vnd.oma-scws-http-response +application/vnd.oma.bcast.associated-procedure-parameter+xml +application/vnd.oma.bcast.drm-trigger+xml +application/vnd.oma.bcast.imd+xml +application/vnd.oma.bcast.ltkm +application/vnd.oma.bcast.notification+xml +application/vnd.oma.bcast.provisioningtrigger +application/vnd.oma.bcast.sgboot +application/vnd.oma.bcast.sgdd+xml +application/vnd.oma.bcast.sgdu +application/vnd.oma.bcast.simple-symbol-container +application/vnd.oma.bcast.smartcard-trigger+xml +application/vnd.oma.bcast.sprov+xml +application/vnd.oma.bcast.stkm +application/vnd.oma.cab-address-book+xml +application/vnd.oma.cab-feature-handler+xml +application/vnd.oma.cab-pcc+xml +application/vnd.oma.cab-subs-invite+xml +application/vnd.oma.cab-user-prefs+xml +application/vnd.oma.dcd +application/vnd.oma.dcdc +application/vnd.oma.dd2+xml dd2 +application/vnd.oma.drm.risd+xml +application/vnd.oma.group-usage-list+xml +application/vnd.oma.lwm2m+cbor +application/vnd.oma.lwm2m+json +application/vnd.oma.lwm2m+tlv +application/vnd.oma.pal+xml +application/vnd.oma.poc.detailed-progress-report+xml +application/vnd.oma.poc.final-report+xml +application/vnd.oma.poc.groups+xml +application/vnd.oma.poc.invocation-descriptor+xml +application/vnd.oma.poc.optimized-progress-report+xml +application/vnd.oma.push +application/vnd.oma.scidm.messages+xml +application/vnd.oma.xcap-directory+xml +application/vnd.omads-email+xml +application/vnd.omads-file+xml +application/vnd.omads-folder+xml +application/vnd.omaloc-supl-init +application/vnd.onepager tam +application/vnd.onepagertamp tamp +application/vnd.onepagertamx tamx +application/vnd.onepagertat tat +application/vnd.onepagertatp tatp +application/vnd.onepagertatx tatx +application/vnd.onvif.metadata +application/vnd.openblox.game+xml obgx +application/vnd.openblox.game-binary obg +application/vnd.openeye.oeb oeb +application/vnd.openofficeorg.extension oxt +application/vnd.openstreetmap.data+xml osm +application/vnd.opentimestamps.ots +application/vnd.openxmlformats-officedocument.custom-properties+xml +application/vnd.openxmlformats-officedocument.customXmlProperties+xml +application/vnd.openxmlformats-officedocument.drawing+xml +application/vnd.openxmlformats-officedocument.drawingml.chart+xml +application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml +application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml +application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml +application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml +application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml +application/vnd.openxmlformats-officedocument.extended-properties+xml +application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml +application/vnd.openxmlformats-officedocument.presentationml.comments+xml +application/vnd.openxmlformats-officedocument.presentationml.handoutMaster+xml +application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml +application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml +application/vnd.openxmlformats-officedocument.presentationml.presentation pptx +application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml +application/vnd.openxmlformats-officedocument.presentationml.presProps+xml +application/vnd.openxmlformats-officedocument.presentationml.slide sldx +application/vnd.openxmlformats-officedocument.presentationml.slide+xml +application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml +application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml +application/vnd.openxmlformats-officedocument.presentationml.slideshow ppsx +application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml +application/vnd.openxmlformats-officedocument.presentationml.slideUpdateInfo+xml +application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml +application/vnd.openxmlformats-officedocument.presentationml.tags+xml +application/vnd.openxmlformats-officedocument.presentationml.template potx +application/vnd.openxmlformats-officedocument.presentationml.template.main+xml +application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.revisionHeaders+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.revisionLog+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx +application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.tableSingleCells+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.template xltx +application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml +application/vnd.openxmlformats-officedocument.theme+xml +application/vnd.openxmlformats-officedocument.themeOverride+xml +application/vnd.openxmlformats-officedocument.vmlDrawing +application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.document docx +application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.template dotx +application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml +application/vnd.openxmlformats-package.core-properties+xml +application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml +application/vnd.openxmlformats-package.relationships+xml +application/vnd.oracle.resource+json +application/vnd.orange.indata +application/vnd.osa.netdeploy ndc +application/vnd.osgeo.mapguide.package mgp +application/vnd.osgi.bundle +application/vnd.osgi.dp dp +application/vnd.osgi.subsystem esa +application/vnd.otps.ct-kip+xml +application/vnd.oxli.countgraph oxlicg +application/vnd.pagerduty+json +application/vnd.palm pdb pqa oprc +application/vnd.panoply plp +application/vnd.paos.xml +application/vnd.patentdive dive +application/vnd.patientecommsdoc +application/vnd.pawaafile paw +application/vnd.pcos +application/vnd.pg.format str +application/vnd.pg.osasli ei6 +application/vnd.piaccess.application-licence pil +application/vnd.picsel efif +application/vnd.pmi.widget wg +application/vnd.poc.group-advertisement+xml +application/vnd.pocketlearn plf +application/vnd.powerbuilder6 pbd +application/vnd.powerbuilder6-s +application/vnd.powerbuilder7 +application/vnd.powerbuilder7-s +application/vnd.powerbuilder75 +application/vnd.powerbuilder75-s +application/vnd.preminet preminet +application/vnd.previewsystems.box box vbox +application/vnd.proteus.magazine mgz +application/vnd.psfs psfs +application/vnd.pt.mundusmundi +application/vnd.publishare-delta-tree qps +application/vnd.pvi.ptid1 ptid +application/vnd.pwg-multiplexed +application/vnd.pwg-xhtml-print+xml +application/vnd.qualcomm.brew-app-res bar +application/vnd.quarantainenet +application/vnd.Quark.QuarkXPress qxd qxt qwd qwt qxl qxb +application/vnd.quobject-quoxdocument quox quiz +application/vnd.radisys.moml+xml +application/vnd.radisys.msml+xml +application/vnd.radisys.msml-audit+xml +application/vnd.radisys.msml-audit-conf+xml +application/vnd.radisys.msml-audit-conn+xml +application/vnd.radisys.msml-audit-dialog+xml +application/vnd.radisys.msml-audit-stream+xml +application/vnd.radisys.msml-conf+xml +application/vnd.radisys.msml-dialog+xml +application/vnd.radisys.msml-dialog-base+xml +application/vnd.radisys.msml-dialog-fax-detect+xml +application/vnd.radisys.msml-dialog-fax-sendrecv+xml +application/vnd.radisys.msml-dialog-group+xml +application/vnd.radisys.msml-dialog-speech+xml +application/vnd.radisys.msml-dialog-transform+xml +application/vnd.rainstor.data tree +application/vnd.rapid +application/vnd.rar rar +application/vnd.realvnc.bed bed +application/vnd.recordare.musicxml mxl +application/vnd.recordare.musicxml+xml +application/vnd.RenLearn.rlprint +application/vnd.resilient.logic rlm reload +application/vnd.restful+json +application/vnd.rig.cryptonote cryptonote +application/vnd.rim.cod cod +application/vnd.route66.link66+xml link66 +application/vnd.rs-274x +application/vnd.ruckus.download +application/vnd.s3sms +application/vnd.sailingtracker.track st +application/vnd.sar SAR +application/vnd.sbm.cid +application/vnd.sbm.mid2 +application/vnd.scribus scd sla slaz +application/vnd.sealed.3df s3df +application/vnd.sealed.csf scsf +application/vnd.sealed.doc sdoc sdo s1w +application/vnd.sealed.eml seml sem +application/vnd.sealed.mht smht smh +application/vnd.sealed.net +application/vnd.sealed.ppt sppt s1p +application/vnd.sealed.tiff stif +application/vnd.sealed.xls sxls sxl s1e +application/vnd.sealedmedia.softseal.html stml s1h +application/vnd.sealedmedia.softseal.pdf spdf spd s1a +application/vnd.seemail see +application/vnd.seis+json +application/vnd.sema sema +application/vnd.semd semd +application/vnd.semf semf +application/vnd.shade-save-file ssv +application/vnd.shana.informed.formdata ifm +application/vnd.shana.informed.formtemplate itp +application/vnd.shana.informed.interchange iif +application/vnd.shana.informed.package ipk +application/vnd.shootproof+json +application/vnd.shopkick+json +application/vnd.shp shp +application/vnd.shx shx +application/vnd.sigrok.session sr +application/vnd.SimTech-MindMapper twd twds +application/vnd.siren+json +application/vnd.smaf mmf +application/vnd.smart.notebook notebook +application/vnd.smart.teacher teacher +application/vnd.smintio.portals.archive sipa +application/vnd.snesdev-page-table ptrom pt +application/vnd.software602.filler.form+xml fo +application/vnd.software602.filler.form-xml-zip zfo +application/vnd.solent.sdkm+xml sdkm sdkd +application/vnd.spotfire.dxp dxp +application/vnd.spotfire.sfs sfs +application/vnd.sqlite3 sqlite sqlite3 +application/vnd.sss-cod +application/vnd.sss-dtf +application/vnd.sss-ntf +application/vnd.stardivision.calc sdc +application/vnd.stardivision.chart sds +application/vnd.stardivision.draw sda +application/vnd.stardivision.impress sdd +application/vnd.stardivision.math smf +application/vnd.stardivision.writer sdw +application/vnd.stardivision.writer-global sgl +application/vnd.stepmania.package smzip +application/vnd.stepmania.stepchart sm +application/vnd.street-stream +application/vnd.sun.wadl+xml wadl +application/vnd.sun.xml.calc sxc +application/vnd.sun.xml.calc.template stc +application/vnd.sun.xml.draw sxd +application/vnd.sun.xml.draw.template std +application/vnd.sun.xml.impress sxi +application/vnd.sun.xml.impress.template sti +application/vnd.sun.xml.math sxm +application/vnd.sun.xml.writer sxw +application/vnd.sun.xml.writer.global sxg +application/vnd.sun.xml.writer.template stw +application/vnd.sus-calendar sus susp +application/vnd.svd +application/vnd.swiftview-ics +application/vnd.sybyl.mol2 ml2 mol2 sy2 +application/vnd.sycle+xml scl +application/vnd.syft+json syft.json +application/vnd.symbian.install sis +application/vnd.syncml+xml xsm +application/vnd.syncml.dm+wbxml bdm +application/vnd.syncml.dm+xml xdm +application/vnd.syncml.dm.notification +application/vnd.syncml.dmddf+wbxml +application/vnd.syncml.dmddf+xml ddf +application/vnd.syncml.dmtnds+wbxml +application/vnd.syncml.dmtnds+xml +application/vnd.syncml.ds.notification +application/vnd.tableschema+json +application/vnd.tao.intent-module-archive tao +application/vnd.tcpdump.pcap pcap cap dmp +application/vnd.theqvd qvd +application/vnd.think-cell.ppttc+json ppttc +application/vnd.tmd.mediaflex.api+xml +application/vnd.tml vfr viaframe +application/vnd.tmobile-livetv tmo +application/vnd.tri.onesource +application/vnd.trid.tpt tpt +application/vnd.triscape.mxs mxs +application/vnd.trueapp tra +application/vnd.truedoc +application/vnd.ubisoft.webplayer +application/vnd.ufdl ufdl ufd frm +application/vnd.uiq.theme utz +application/vnd.umajin umj +application/vnd.unity unityweb +application/vnd.uoml+xml uoml uo +application/vnd.uplanet.alert +application/vnd.uplanet.alert-wbxml +application/vnd.uplanet.bearer-choice +application/vnd.uplanet.bearer-choice-wbxml +application/vnd.uplanet.cacheop +application/vnd.uplanet.cacheop-wbxml +application/vnd.uplanet.channel +application/vnd.uplanet.channel-wbxml +application/vnd.uplanet.list +application/vnd.uplanet.list-wbxml +application/vnd.uplanet.listcmd +application/vnd.uplanet.listcmd-wbxml +application/vnd.uplanet.signal +application/vnd.uri-map urim urimap +application/vnd.valve.source.material vmt +application/vnd.vcx vcx +application/vnd.vd-study mxi study-inter model-inter +application/vnd.vectorworks vwx +application/vnd.vel+json +application/vnd.verimatrix.vcas +application/vnd.veritone.aion+json aion vtnstd +application/vnd.veryant.thin istc isws +application/vnd.ves.encrypted VES +application/vnd.vidsoft.vidconference vsc +application/vnd.visio vsd vst vsw vss +application/vnd.visionary vis +application/vnd.vividence.scriptfile +application/vnd.vsf vsf +application/vnd.wap.sic sic +application/vnd.wap.slc slc +application/vnd.wap.wbxml wbxml +application/vnd.wap.wmlc wmlc +application/vnd.wap.wmlscriptc wmlsc +application/vnd.wasmflow.wafl wafl +application/vnd.webturbo wtb +application/vnd.wfa.dpp +application/vnd.wfa.p2p p2p +application/vnd.wfa.wsc wsc +application/vnd.windows.devicepairing +application/vnd.wmc wmc +application/vnd.wmf.bootstrap +application/vnd.wolfram.mathematica nb +application/vnd.wolfram.mathematica.package m +application/vnd.wolfram.player nbp +application/vnd.wordlift +application/vnd.wordperfect wpd +application/vnd.wqd wqd +application/vnd.wrq-hp3000-labelled +application/vnd.wt.stf stf +application/vnd.wv.csp+wbxml wv +application/vnd.wv.csp+xml +application/vnd.wv.ssp+xml +application/vnd.xacml+json +application/vnd.xara xar +application/vnd.xfdl xfdl xfd +application/vnd.xfdl.webform +application/vnd.xmi+xml +application/vnd.xmpie.cpkg cpkg +application/vnd.xmpie.dpkg dpkg +application/vnd.xmpie.plan +application/vnd.xmpie.ppkg ppkg +application/vnd.xmpie.xlim xlim +application/vnd.yamaha.hv-dic hvd +application/vnd.yamaha.hv-script hvs +application/vnd.yamaha.hv-voice hvp +application/vnd.yamaha.openscoreformat osf +application/vnd.yamaha.openscoreformat.osfpvg+xml +application/vnd.yamaha.remote-setup +application/vnd.yamaha.smaf-audio saf +application/vnd.yamaha.smaf-phrase spf +application/vnd.yamaha.through-ngn +application/vnd.yamaha.tunnel-udpencap +application/vnd.yaoweme yme +application/vnd.yellowriver-custom-menu cmp +application/vnd.zul zir zirz +application/vnd.zzazz.deck+xml zaz +application/voicexml+xml vxml +application/voucher-cms+json vcj +application/vq-rtcpxr +application/wasm wasm +application/watcherinfo+xml wif +application/webpush-options+json +application/whoispp-query +application/whoispp-response +application/widget wgt +application/wita +application/wordperfect5.1 +application/wsdl+xml wsdl +application/wspolicy+xml wspolicy +application/x-123 wk +application/x-7z-compressed 7z +application/x-abiword abw +application/x-apple-diskimage dmg +application/x-bcpio bcpio +application/x-bittorrent torrent +application/x-cdf cdf cda +application/x-cdlink vcd +application/x-comsol mph +application/x-cpio cpio +application/x-csh csh +application/x-director dcr dir dxr +application/x-doom wad +application/x-dvi dvi +application/x-font pfa pfb gsf +application/x-font-pcf pcf pcf.Z +application/x-freemind mm +application/x-ganttproject gan +application/x-gnumeric gnumeric +application/x-go-sgf sgf +application/x-graphing-calculator gcf +application/x-gtar gtar +application/x-gtar-compressed tgz taz +application/x-hdf hdf +application/x-hwp hwp +application/x-ica ica +application/x-info info +application/x-internet-signup ins isp +application/x-iphone iii +application/x-iso9660-image iso +application/x-java-jnlp-file jnlp +application/x-jmol jmz +application/x-killustrator kil +application/x-latex latex +application/x-lha lha +application/x-lyx lyx +application/x-lzh lzh +application/x-lzx lzx +application/x-maker frm maker frame fm fb book fbdoc +application/x-ms-wmd wmd +application/x-ms-wmz wmz +application/x-msdos-program com exe bat dll +application/x-msi msi +application/x-netcdf nc +application/x-ns-proxy-autoconfig pac +application/x-nwc nwc +application/x-object o +application/x-oz-application oza +application/x-pkcs7-certreqresp p7r +application/x-pki-message +application/x-python-code pyc pyo +application/x-qgis qgs shp shx +application/x-quicktimeplayer qtl +application/x-rdp rdp +application/x-redhat-package-manager rpm +application/x-rss+xml rss +application/x-ruby rb +application/x-scilab sci sce +application/x-scilab-xcos xcos +application/x-sh sh +application/x-shar shar +application/x-silverlight scr +application/x-stuffit sit sitx +application/x-sv4cpio sv4cpio +application/x-sv4crc sv4crc +application/x-tar tar +application/x-tcl tcl +application/x-tex-gf gf +application/x-tex-pk pk +application/x-texinfo texinfo texi +application/x-trash ~ % bak old sik +application/x-troff-man man +application/x-troff-me me +application/x-troff-ms ms +application/x-ustar ustar +application/x-wais-source src +application/x-wingz wz +application/x-www-form-urlencoded +application/x-x509-ca-cert crt +application/x-x509-ca-ra-cert +application/x-x509-next-ca-cert +application/x-xfig fig +application/x-xpinstall xpi +application/x-xz xz +application/x400-bp +application/xacml+xml +application/xcap-att+xml xav +application/xcap-caps+xml xca +application/xcap-diff+xml xdf +application/xcap-el+xml xel +application/xcap-error+xml xer +application/xcap-ns+xml xns +application/xcon-conference-info+xml +application/xcon-conference-info-diff+xml +application/xenc+xml +application/xfdf xfdf +application/xhtml+xml xhtml xhtm xht +application/xliff+xml xlf +application/xml xml +application/xml-dtd dtd mod +application/xml-external-parsed-entity ent +application/xml-patch+xml +application/xmpp+xml +application/xop+xml xop +application/xslt+xml xsl xslt +application/xspf+xml xspf +application/xv+xml mxml xhvml xvml xvm +application/yaml yaml yml +application/yang yang +application/yang-data+cbor +application/yang-data+json +application/yang-data+xml +application/yang-patch+json +application/yang-patch+xml +application/yin+xml yin +application/zip zip +application/zlib +application/zstd zst + +audio/1d-interleaved-parityfec +audio/32kadpcm 726 +audio/3gpp +audio/3gpp2 +audio/aac adts aac ass +audio/ac3 ac3 +audio/AMR amr AMR +audio/AMR-WB awb AWB +audio/amr-wb+ +audio/annodex axa +audio/aptx +audio/asc acn +audio/ATRAC-ADVANCED-LOSSLESS aal +audio/ATRAC-X atx +audio/ATRAC3 at3 aa3 omg +audio/basic au snd +audio/BV16 +audio/BV32 +audio/clearmode +audio/CN +audio/csound csd orc sco +audio/DAT12 +audio/dls dls +audio/dsr-es201108 +audio/dsr-es202050 +audio/dsr-es202211 +audio/dsr-es202212 +audio/DV +audio/DVI4 +audio/eac3 +audio/encaprtp +audio/EVRC evc +audio/EVRC-QCP qcp QCP +audio/EVRC0 +audio/EVRC1 +audio/EVRCB evb +audio/EVRCB0 +audio/EVRCB1 +audio/EVRCNW enw +audio/EVRCNW0 +audio/EVRCNW1 +audio/EVRCWB evw +audio/EVRCWB0 +audio/EVRCWB1 +audio/EVS +audio/example +audio/flac flac +audio/flexfec +audio/fwdred +audio/G711-0 +audio/G719 +audio/G722 +audio/G7221 +audio/G723 +audio/G726-16 +audio/G726-24 +audio/G726-32 +audio/G726-40 +audio/G728 +audio/G729 +audio/G7291 +audio/G729D +audio/G729E +audio/GSM +audio/GSM-EFR +audio/GSM-HR-08 +audio/iLBC lbc +audio/ip-mr_v2.5 +audio/L16 l16 +audio/L20 +audio/L24 +audio/L8 +audio/LPC +audio/MELP +audio/MELP1200 +audio/MELP2400 +audio/MELP600 +audio/mhas mhas +audio/mobile-xmf mxmf +audio/mp4 m4a +audio/MP4A-LATM +audio/MPA +audio/mpa-robust +audio/mpeg mpga mpega mp1 mp2 mp3 +audio/mpeg4-generic +audio/mpegurl m3u +audio/ogg oga ogg opus spx +audio/opus +audio/parityfec +audio/PCMA +audio/PCMA-WB +audio/PCMU +audio/PCMU-WB +audio/prs.sid sid psid +audio/QCELP +audio/raptorfec +audio/RED +audio/rtp-enc-aescm128 +audio/rtp-midi +audio/rtploopback +audio/rtx +audio/scip +audio/SMV smv +audio/SMV-QCP +audio/SMV0 +audio/sofa sofa +audio/sp-midi mid +audio/speex +audio/t140c +audio/t38 +audio/telephone-event +audio/TETRA_ACELP +audio/TETRA_ACELP_BB +audio/tone +audio/TSVCIS +audio/UEMCLIP +audio/ulpfec +audio/usac loas xhe +audio/VDVI +audio/VMR-WB +audio/vnd.3gpp.iufp +audio/vnd.4SB +audio/vnd.audiokoz koz +audio/vnd.CELP +audio/vnd.cisco.nse +audio/vnd.cmles.radio-events +audio/vnd.cns.anp1 +audio/vnd.cns.inf1 +audio/vnd.dece.audio uva uvva +audio/vnd.digital-winds eol +audio/vnd.dlna.adts +audio/vnd.dolby.heaac.1 +audio/vnd.dolby.heaac.2 +audio/vnd.dolby.mlp mlp +audio/vnd.dolby.mps +audio/vnd.dolby.pl2 +audio/vnd.dolby.pl2x +audio/vnd.dolby.pl2z +audio/vnd.dolby.pulse.1 +audio/vnd.dra +audio/vnd.dts dts +audio/vnd.dts.hd dtshd +audio/vnd.dts.uhd +audio/vnd.dvb.file +audio/vnd.everad.plj plj +audio/vnd.hns.audio +audio/vnd.lucent.voice lvp +audio/vnd.ms-playready.media.pya pya +audio/vnd.nokia.mobile-xmf +audio/vnd.nortel.vbk vbk +audio/vnd.nuera.ecelp4800 ecelp4800 +audio/vnd.nuera.ecelp7470 ecelp7470 +audio/vnd.nuera.ecelp9600 ecelp9600 +audio/vnd.octel.sbc +audio/vnd.presonus.multitrack multitrack +audio/vnd.rhetorex.32kadpcm +audio/vnd.rip rip +audio/vnd.sealedmedia.softseal.mpeg smp3 smp s1m +audio/vnd.vmx.cvsd +audio/vorbis +audio/vorbis-config +audio/x-aiff aif aiff aifc +audio/x-gsm gsm +audio/x-ms-wax wax +audio/x-ms-wma wma +audio/x-pn-realaudio ra rm ram +audio/x-scpls pls +audio/x-sd2 sd2 +audio/x-wav wav + +chemical/x-alchemy alc +chemical/x-cache cac cache +chemical/x-cache-csf csf +chemical/x-cactvs-binary cbin cascii ctab +chemical/x-cdx cdx +chemical/x-cerius +chemical/x-chem3d c3d +chemical/x-chemdraw chm +chemical/x-cif cif +chemical/x-cmdf cmdf +chemical/x-cml cml +chemical/x-compass cpa +chemical/x-crossfire bsd +chemical/x-csml csml csm +chemical/x-ctx ctx +chemical/x-cxf cxf cef +#chemical/x-daylight-smiles smi +chemical/x-embl-dl-nucleotide emb embl +chemical/x-galactic-spc spc +chemical/x-gamess-input inp gam gamin +chemical/x-gaussian-checkpoint fch fchk +chemical/x-gaussian-cube cub +chemical/x-gaussian-input gau gjc gjf +chemical/x-gaussian-log gal +chemical/x-gcg8-sequence gcg +chemical/x-genbank gen +chemical/x-hin hin +chemical/x-isostar istr ist +chemical/x-jcamp-dx jdx dx +chemical/x-kinemage kin +chemical/x-macmolecule mcm +chemical/x-macromodel-input mmod +chemical/x-mdl-molfile mol +chemical/x-mdl-rdfile rd +chemical/x-mdl-rxnfile rxn +chemical/x-mdl-sdfile sd sdf +chemical/x-mdl-tgf tgf +#chemical/x-mif mif +chemical/x-mmcif mcif +chemical/x-molconn-Z b +chemical/x-mopac-graph gpt +chemical/x-mopac-input mop mopcrt mpc zmt +chemical/x-mopac-out moo +chemical/x-mopac-vib mvb +chemical/x-ncbi-asn1 asn +chemical/x-ncbi-asn1-ascii prt +chemical/x-ncbi-asn1-binary val aso +chemical/x-ncbi-asn1-spec asn +chemical/x-pdb pdb +chemical/x-rosdal ros +chemical/x-swissprot sw +chemical/x-vamas-iso14976 vms +chemical/x-vmd vmd +chemical/x-xtel xtel +chemical/x-xyz xyz + +font/collection ttc +font/otf otf +font/sfnt +font/ttf ttf +font/woff woff +font/woff2 woff2 + +image/aces exr +image/apng apng +image/avci avci +image/avcs avcs +image/avif avif hif +image/bmp bmp +image/cgm cgm +image/dicom-rle drle +image/dpx dpx +image/emf emf +image/example +image/fits fits fit fts +image/g3fax +image/gif gif +image/heic heic +image/heic-sequence heics +image/heif heif +image/heif-sequence heifs +image/hej2k hej2 +image/hsj2 hsj2 +image/ief ief +image/j2c j2c J2C j2k J2K +image/jls jls +image/jp2 jp2 jpg2 +image/jpeg jpeg jpg jpe jfif +image/jph jph +image/jphc jhc jphc +image/jpm jpm jpgm +image/jpx jpx jpf +image/jxl jxl +image/jxr jxr +image/jxrA jxra +image/jxrS jxrs +image/jxs jxs +image/jxsc jxsc +image/jxsi jxsi +image/jxss jxss +image/ktx ktx +image/ktx2 ktx2 +image/naplps +image/png png +image/prs.btif btif btf +image/prs.pti pti +image/pwg-raster +image/svg+xml svg svgz +image/t38 +image/tiff tiff tif +image/tiff-fx tfx +image/vnd.adobe.photoshop psd +image/vnd.airzip.accelerator.azv azv +image/vnd.cns.inf2 +image/vnd.dece.graphic uvi uvvi uvg uvvg +image/vnd.djvu djvu djv +image/vnd.dvb.subtitle +image/vnd.dwg dwg +image/vnd.dxf dxf +image/vnd.fastbidsheet fbs +image/vnd.fpx fpx +image/vnd.fst fst +image/vnd.fujixerox.edmics-mmr mmr +image/vnd.fujixerox.edmics-rlc rlc +image/vnd.globalgraphics.pgb PGB pgb +image/vnd.microsoft.icon ico +image/vnd.mix +image/vnd.ms-modi mdi +image/vnd.net-fpx +image/vnd.pco.b16 b16 +image/vnd.radiance hdr rgbe xyze +image/vnd.sealed.png spng spn s1n +image/vnd.sealedmedia.softseal.gif sgif sgi s1g +image/vnd.sealedmedia.softseal.jpg sjpg sjp s1j +image/vnd.svf +image/vnd.tencent.tap tap +image/vnd.valve.source.texture vtf +image/vnd.wap.wbmp wbmp +image/vnd.xiff xif +image/vnd.zbrush.pcx pcx +image/webp webp +image/wmf wmf +image/x-canon-cr2 cr2 +image/x-canon-crw crw +image/x-cmu-raster ras +image/x-coreldraw cdr +image/x-coreldrawpattern pat +image/x-coreldrawtemplate cdt +image/x-corelphotopaint cpt +image/x-epson-erf erf +image/x-jg art +image/x-jng jng +image/x-nikon-nef nef +image/x-olympus-orf orf +image/x-portable-anymap pnm +image/x-portable-bitmap pbm +image/x-portable-graymap pgm +image/x-portable-pixmap ppm +image/x-rgb rgb +image/x-xbitmap xbm +image/x-xcf xcf +image/x-xpixmap xpm +image/x-xwindowdump xwd + +inode/blockdevice +inode/chardevice +inode/directory +inode/directory-locked +inode/fifo +inode/socket + +message/bhttp +message/CPIM +message/delivery-status +message/disposition-notification +message/example +message/external-body +message/feedback-report +message/global u8msg +message/global-delivery-status u8dsn +message/global-disposition-notification u8mdn +message/global-headers u8hdr +message/http +message/imdn+xml +message/mls +message/ohttp-req +message/ohttp-res +message/partial +message/rfc822 eml mail art +message/s-http +message/sip +message/sipfrag +message/tracking-status +message/vnd.wfa.wsc + +model/3mf +model/e57 +model/example +model/gltf+json gltf +model/gltf-binary glb +model/iges igs iges +model/JT jt +model/mesh msh mesh silo +model/mtl mtl +model/obj obj +model/prc prc +model/step stp step +model/step+xml stpx +model/step+zip stpz +model/step-xml+zip stpxz +model/stl stl +model/u3d u3d +model/vnd.bary bary +model/vnd.cld cld +model/vnd.collada+xml dae +model/vnd.dwf dwf +model/vnd.flatland.3dml +model/vnd.gdl gdl gsm win dor lmp rsm msm ism +model/vnd.gs-gdl +model/vnd.gtw gtw +model/vnd.moml+xml moml +model/vnd.mts mts +model/vnd.opengex ogex +model/vnd.parasolid.transmit.binary x_b xmt_bin +model/vnd.parasolid.transmit.text x_t xmt_txt +model/vnd.pytha.pyox pyox +model/vnd.rosette.annotated-data-model +model/vnd.sap.vds vds +model/vnd.usda usda +model/vnd.usdz+zip usdz +model/vnd.valve.source.compiled-map bsp +model/vnd.vtu vtu +model/vrml wrl vrm vrml +model/x3d+fastinfoset x3db +model/x3d+xml x3d x3dz +model/x3d-vrml x3dv x3dvz + +multipart/alternative +multipart/appledouble +multipart/byteranges +multipart/digest +multipart/encrypted +multipart/example +multipart/form-data +multipart/header-set +multipart/mixed +multipart/multilingual +multipart/parallel +multipart/related +multipart/report +multipart/signed +multipart/vnd.bint.med-plus bmed +multipart/voice-message vpm +multipart/x-mixed-replace + +text/1d-interleaved-parityfec +text/cache-manifest appcache manifest +text/calendar ics ifb +text/cql CQL +text/cql-extension +text/cql-identifier +text/css css +text/csv csv +text/csv-schema csvs +text/dns soa zone +text/encaprtp +text/enriched +text/example +text/fhirpath +text/flexfec +text/fwdred +text/gff3 gff3 +text/grammar-ref-list +text/hl7v2 +text/html html htm shtml +text/javascript es js mjs +text/jcr-cnd cnd +text/markdown md markdown +text/mizar miz +text/n3 n3 +text/parameters +text/parityfec +text/plain txt text pot brf srt +text/provenance-notation provn +text/prs.fallenstein.rst rst +text/prs.lines.tag tag dsc +text/prs.prop.logic +text/raptorfec +text/RED +text/rfc822-headers +text/rtf +text/rtp-enc-aescm128 +text/rtploopback +text/rtx +text/SGML sgml sgm +text/shaclc shaclc shc +text/shex shex +text/spdx spdx +text/strings +text/t140 +text/tab-separated-values tsv +text/texmacs tm +text/troff t tr roff +text/turtle ttl +text/ulpfec +text/uri-list uris uri +text/vcard vcf vcard +text/vnd.a a +text/vnd.abc abc +text/vnd.ascii-art ascii +text/vnd.curl curl +text/vnd.debian.copyright copyright +text/vnd.DMClientScript dms +text/vnd.dvb.subtitle +text/vnd.esmertec.theme-descriptor jtd +text/vnd.exchangeable VFK +text/vnd.familysearch.gedcom ged +text/vnd.ficlab.flt flt +text/vnd.fly fly +text/vnd.fmi.flexstor flx +text/vnd.gml +text/vnd.graphviz gv dot +text/vnd.hans hans +text/vnd.hgl hgl +text/vnd.in3d.3dml 3dml 3dm +text/vnd.in3d.spot spot spo +text/vnd.IPTC.NewsML +text/vnd.IPTC.NITF +text/vnd.latex-z +text/vnd.motorola.reflex +text/vnd.ms-mediapackage mpf +text/vnd.net2phone.commcenter.command ccc +text/vnd.radisys.msml-basic-layout +text/vnd.senx.warpscript mc2 +text/vnd.sosi sos +text/vnd.sun.j2me.app-descriptor jad +text/vnd.trolltech.linguist ts +text/vnd.wap.si si +text/vnd.wap.sl sl +text/vnd.wap.wml wml +text/vnd.wap.wmlscript wmls +text/vtt vtt +text/wgsl wgsl +text/x-bibtex bib +text/x-boo boo +text/x-c++hdr h++ hpp hxx hh +text/x-c++src c++ cpp cxx cc +text/x-chdr h +text/x-component htc +text/x-csh csh +text/x-csrc c +text/x-diff diff patch +text/x-dsrc d +text/x-haskell hs +text/x-java java +text/x-lilypond ly +text/x-literate-haskell lhs +text/x-moc moc +text/x-pascal p pas +text/x-pcs-gcd gcd +text/x-perl pl pm +text/x-python py +text/x-scala scala +text/x-setext etx +text/x-sfv sfv +text/x-sh sh +text/x-tcl tcl tk +text/x-tex tex ltx sty cls +text/x-vcalendar vcs +text/xml +text/xml-dtd +text/xml-external-parsed-entity + +video/1d-interleaved-parityfec +video/3gpp +video/3gpp-tt +video/3gpp2 +video/annodex axv +video/AV1 +video/BMPEG +video/BT656 +video/CelB +video/DV +video/dv dif dv +video/encaprtp +video/example +video/FFV1 +video/flexfec +video/fli fli +video/gl gl +video/H261 +video/H263 +video/H263-1998 +video/H263-2000 +video/H264 +video/H264-RCDO +video/H264-SVC +video/H265 +video/H266 +video/iso.segment m4s +video/JPEG +video/jpeg2000 +video/jxsv +video/mj2 mj2 mjp2 +video/MP1S +video/MP2P +video/MP2T +video/mp4 mp4 mpg4 m4v +video/MP4V-ES +video/mpeg mpeg mpg mpe m1v m2v +video/mpeg4-generic +video/MPV +video/nv +video/ogg ogv +video/parityfec +video/pointer +video/quicktime qt mov +video/raptorfec +video/raw +video/rtp-enc-aescm128 +video/rtploopback +video/rtx +video/scip +video/smpte291 +video/SMPTE292M +video/ulpfec +video/vc1 +video/vc2 +video/vnd.CCTV +video/vnd.dece.hd uvh uvvh +video/vnd.dece.mobile uvm uvvm +video/vnd.dece.mp4 uvu uvvu +video/vnd.dece.pd uvp uvvp +video/vnd.dece.sd uvs uvvs +video/vnd.dece.video uvv uvvv +video/vnd.directv.mpeg +video/vnd.directv.mpeg-tts +video/vnd.dlna.mpeg-tts +video/vnd.dvb.file dvb +video/vnd.fvt fvt +video/vnd.hns.video +video/vnd.iptvforum.1dparityfec-1010 +video/vnd.iptvforum.1dparityfec-2005 +video/vnd.iptvforum.2dparityfec-1010 +video/vnd.iptvforum.2dparityfec-2005 +video/vnd.iptvforum.ttsavc +video/vnd.iptvforum.ttsmpeg2 +video/vnd.motorola.video +video/vnd.motorola.videop +video/vnd.mpegurl mxu m4u +video/vnd.ms-playready.media.pyv pyv +video/vnd.nokia.interleaved-multimedia nim +video/vnd.nokia.mp4vr +video/vnd.nokia.videovoip +video/vnd.objectvideo +video/vnd.radgamettools.bink bik bk2 +video/vnd.radgamettools.smacker smk +video/vnd.sealed.mpeg1 smpg s11 +video/vnd.sealed.mpeg4 s14 +video/vnd.sealed.swf sswf ssw +video/vnd.sealedmedia.softseal.mov smov smo s1q +video/vnd.uvvu.mp4 +video/vnd.vivo viv +video/vnd.youtube.yt yt +video/VP8 +video/VP9 +video/webm webm +video/x-flv flv +video/x-la-asf lsf lsx +video/x-matroska mpv mkv +video/x-mng mng +video/x-ms-wm wm +video/x-ms-wmv wmv +video/x-ms-wmx wmx +video/x-ms-wvx wvx +video/x-msvideo avi +video/x-sgi-movie movie
\ No newline at end of file diff --git a/tests/res/raw/mime_types b/tests/res/raw/mime_types new file mode 100644 index 000000000..57fa013c2 --- /dev/null +++ b/tests/res/raw/mime_types @@ -0,0 +1,846 @@ +############################################################################### +# +# MIME media types and the extensions that represent them. +# +# The format of this file is a media type on the left and zero or more +# filename extensions on the right. Programs using this file will map +# files ending with those extensions to the associated type. +# +# This file is part of the "mime-support" package. Please report a bug using +# the "reportbug" command of the "reportbug" package if you would like new +# types or extensions to be added. +# +# The reason that all types are managed by the mime-support package instead +# allowing individual packages to install types in much the same way as they +# add entries in to the mailcap file is so these types can be referenced by +# other programs (such as a web server) even if the specific support package +# for that type is not installed. +# +# Users can add their own types if they wish by creating a ".mime.types" +# file in their home directory. Definitions included there will take +# precedence over those listed here. +# +############################################################################### + + +application/activemessage +application/andrew-inset ez +application/annodex anx +application/applefile +application/atom+xml atom +application/atomcat+xml atomcat +application/atomicmail +application/atomserv+xml atomsrv +application/batch-SMTP +application/bbolin lin +application/beep+xml +application/cals-1840 +application/commonground +application/cu-seeme cu +application/cybercash +application/davmount+xml davmount +application/dca-rft +application/dec-dx +application/dicom dcm +application/docbook+xml +application/dsptype tsp +application/dvcs +application/ecmascript es +application/edi-consent +application/edi-x12 +application/edifact +application/epub+zip epub +application/eshop +application/font-sfnt otf ttf +application/font-tdpfr pfr +application/font-woff woff +application/futuresplash spl +application/ghostview +application/gzip gz +application/hta hta +application/http +application/hyperstudio +application/iges +application/index +application/index.cmd +application/index.obj +application/index.response +application/index.vnd +application/iotp +application/ipp +application/isup +application/java-archive jar +application/java-serialized-object ser +application/java-vm class +application/javascript js mjs +application/json json +application/ld+json jsonld +application/m3g m3g +application/mac-binhex40 hqx +application/mac-compactpro cpt +application/macwriteii +application/marc +application/mathematica nb nbp +application/mbox mbox +application/ms-tnef +application/msaccess mdb +application/msword doc dot +application/mxf mxf +application/news-message-id +application/news-transmission +application/ocsp-request +application/ocsp-response +application/octet-stream bin deploy msu msp +application/oda oda +application/oebps-package+xml opf +application/ogg ogx +application/onenote one onetoc2 onetmp onepkg +application/parityfec +application/pdf pdf +application/pgp-encrypted pgp +application/pgp-keys key +application/pgp-signature sig +application/pics-rules prf +application/pkcs10 +application/pkcs7-mime +application/pkcs7-signature +application/pkix-cert +application/pkix-crl +application/pkixcmp +application/postscript ps ai eps epsi epsf eps2 eps3 +application/prs.alvestrand.titrax-sheet +application/prs.cww +application/prs.nprend +application/qsig +application/rar rar +application/rdf+xml rdf +application/remote-printing +application/riscos +application/rtf rtf +application/sdp +application/set-payment +application/set-payment-initiation +application/set-registration +application/set-registration-initiation +application/sgml +application/sgml-open-catalog +application/sieve +application/sla stl +application/slate +application/smil+xml smi smil +application/timestamp-query +application/timestamp-reply +application/vemmi +application/wasm wasm +application/whoispp-query +application/whoispp-response +application/wita +application/x400-bp +application/xhtml+xml xhtml xht +application/xml xml xsd +application/xml-dtd +application/xml-external-parsed-entity +application/xslt+xml xsl xslt +application/xspf+xml xspf +application/zip zip +application/vnd.3M.Post-it-Notes +application/vnd.accpac.simply.aso +application/vnd.accpac.simply.imp +application/vnd.acucobol +application/vnd.aether.imp +application/vnd.android.package-archive apk +application/vnd.anser-web-certificate-issue-initiation +application/vnd.anser-web-funds-transfer-initiation +application/vnd.audiograph +application/vnd.bmi +application/vnd.businessobjects +application/vnd.canon-cpdl +application/vnd.canon-lips +application/vnd.cinderella cdy +application/vnd.claymore +application/vnd.commerce-battelle +application/vnd.commonspace +application/vnd.comsocaller +application/vnd.contact.cmsg +application/vnd.cosmocaller +application/vnd.ctc-posml +application/vnd.cups-postscript +application/vnd.cups-raster +application/vnd.cups-raw +application/vnd.cybank +application/vnd.debian.binary-package deb ddeb udeb +application/vnd.dna +application/vnd.dpgraph +application/vnd.dxr +application/vnd.ecdis-update +application/vnd.ecowin.chart +application/vnd.ecowin.filerequest +application/vnd.ecowin.fileupdate +application/vnd.ecowin.series +application/vnd.ecowin.seriesrequest +application/vnd.ecowin.seriesupdate +application/vnd.enliven +application/vnd.epson.esf +application/vnd.epson.msf +application/vnd.epson.quickanime +application/vnd.epson.salt +application/vnd.epson.ssf +application/vnd.ericsson.quickcall +application/vnd.eudora.data +application/vnd.fdf +application/vnd.ffsns +application/vnd.flographit +application/vnd.font-fontforge-sfd sfd +application/vnd.framemaker +application/vnd.fsc.weblaunch +application/vnd.fujitsu.oasys +application/vnd.fujitsu.oasys2 +application/vnd.fujitsu.oasys3 +application/vnd.fujitsu.oasysgp +application/vnd.fujitsu.oasysprs +application/vnd.fujixerox.ddd +application/vnd.fujixerox.docuworks +application/vnd.fujixerox.docuworks.binder +application/vnd.fut-misnet +application/vnd.google-earth.kml+xml kml +application/vnd.google-earth.kmz kmz +application/vnd.grafeq +application/vnd.groove-account +application/vnd.groove-identity-message +application/vnd.groove-injector +application/vnd.groove-tool-message +application/vnd.groove-tool-template +application/vnd.groove-vcard +application/vnd.hhe.lesson-player +application/vnd.hp-HPGL +application/vnd.hp-PCL +application/vnd.hp-PCLXL +application/vnd.hp-hpid +application/vnd.hp-hps +application/vnd.httphone +application/vnd.hzn-3d-crossword +application/vnd.ibm.MiniPay +application/vnd.ibm.afplinedata +application/vnd.ibm.modcap +application/vnd.informix-visionary +application/vnd.intercon.formnet +application/vnd.intertrust.digibox +application/vnd.intertrust.nncp +application/vnd.intu.qbo +application/vnd.intu.qfx +application/vnd.irepository.package+xml +application/vnd.is-xpr +application/vnd.japannet-directory-service +application/vnd.japannet-jpnstore-wakeup +application/vnd.japannet-payment-wakeup +application/vnd.japannet-registration +application/vnd.japannet-registration-wakeup +application/vnd.japannet-setstore-wakeup +application/vnd.japannet-verification +application/vnd.japannet-verification-wakeup +application/vnd.koan +application/vnd.lotus-1-2-3 +application/vnd.lotus-approach +application/vnd.lotus-freelance +application/vnd.lotus-notes +application/vnd.lotus-organizer +application/vnd.lotus-screencam +application/vnd.lotus-wordpro +application/vnd.mcd +application/vnd.mediastation.cdkey +application/vnd.meridian-slingshot +application/vnd.mif +application/vnd.minisoft-hp3000-save +application/vnd.mitsubishi.misty-guard.trustweb +application/vnd.mobius.daf +application/vnd.mobius.dis +application/vnd.mobius.msl +application/vnd.mobius.plc +application/vnd.mobius.txf +application/vnd.motorola.flexsuite +application/vnd.motorola.flexsuite.adsi +application/vnd.motorola.flexsuite.fis +application/vnd.motorola.flexsuite.gotap +application/vnd.motorola.flexsuite.kmr +application/vnd.motorola.flexsuite.ttc +application/vnd.motorola.flexsuite.wem +application/vnd.mozilla.xul+xml xul +application/vnd.ms-artgalry +application/vnd.ms-asf +application/vnd.ms-excel xls xlb xlt +application/vnd.ms-excel.addin.macroEnabled.12 xlam +application/vnd.ms-excel.sheet.binary.macroEnabled.12 xlsb +application/vnd.ms-excel.sheet.macroEnabled.12 xlsm +application/vnd.ms-excel.template.macroEnabled.12 xltm +application/vnd.ms-fontobject eot +application/vnd.ms-lrm +application/vnd.ms-officetheme thmx +application/vnd.ms-pki.seccat cat +#application/vnd.ms-pki.stl stl +application/vnd.ms-powerpoint ppt pps +application/vnd.ms-powerpoint.addin.macroEnabled.12 ppam +application/vnd.ms-powerpoint.presentation.macroEnabled.12 pptm +application/vnd.ms-powerpoint.slide.macroEnabled.12 sldm +application/vnd.ms-powerpoint.slideshow.macroEnabled.12 ppsm +application/vnd.ms-powerpoint.template.macroEnabled.12 potm +application/vnd.ms-project +application/vnd.ms-tnef +application/vnd.ms-word.document.macroEnabled.12 docm +application/vnd.ms-word.template.macroEnabled.12 dotm +application/vnd.ms-works +application/vnd.mseq +application/vnd.msign +application/vnd.music-niff +application/vnd.musician +application/vnd.netfpx +application/vnd.noblenet-directory +application/vnd.noblenet-sealer +application/vnd.noblenet-web +application/vnd.novadigm.EDM +application/vnd.novadigm.EDX +application/vnd.novadigm.EXT +application/vnd.oasis.opendocument.chart odc +application/vnd.oasis.opendocument.database odb +application/vnd.oasis.opendocument.formula odf +application/vnd.oasis.opendocument.graphics odg +application/vnd.oasis.opendocument.graphics-template otg +application/vnd.oasis.opendocument.image odi +application/vnd.oasis.opendocument.presentation odp +application/vnd.oasis.opendocument.presentation-template otp +application/vnd.oasis.opendocument.spreadsheet ods +application/vnd.oasis.opendocument.spreadsheet-template ots +application/vnd.oasis.opendocument.text odt +application/vnd.oasis.opendocument.text-master odm +application/vnd.oasis.opendocument.text-template ott +application/vnd.oasis.opendocument.text-web oth +application/vnd.openxmlformats-officedocument.presentationml.presentation pptx +application/vnd.openxmlformats-officedocument.presentationml.slide sldx +application/vnd.openxmlformats-officedocument.presentationml.slideshow ppsx +application/vnd.openxmlformats-officedocument.presentationml.template potx +application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx +application/vnd.openxmlformats-officedocument.spreadsheetml.template xltx +application/vnd.openxmlformats-officedocument.wordprocessingml.document docx +application/vnd.openxmlformats-officedocument.wordprocessingml.template dotx +application/vnd.osa.netdeploy +application/vnd.palm +application/vnd.pg.format +application/vnd.pg.osasli +application/vnd.powerbuilder6 +application/vnd.powerbuilder6-s +application/vnd.powerbuilder7 +application/vnd.powerbuilder7-s +application/vnd.powerbuilder75 +application/vnd.powerbuilder75-s +application/vnd.previewsystems.box +application/vnd.publishare-delta-tree +application/vnd.pvi.ptid1 +application/vnd.pwg-xhtml-print+xml +application/vnd.rapid +application/vnd.rim.cod cod +application/vnd.s3sms +application/vnd.seemail +application/vnd.shana.informed.formdata +application/vnd.shana.informed.formtemplate +application/vnd.shana.informed.interchange +application/vnd.shana.informed.package +application/vnd.smaf mmf +application/vnd.sss-cod +application/vnd.sss-dtf +application/vnd.sss-ntf +application/vnd.stardivision.calc sdc +application/vnd.stardivision.chart sds +application/vnd.stardivision.draw sda +application/vnd.stardivision.impress sdd +application/vnd.stardivision.math sdf +application/vnd.stardivision.writer sdw +application/vnd.stardivision.writer-global sgl +application/vnd.street-stream +application/vnd.sun.xml.calc sxc +application/vnd.sun.xml.calc.template stc +application/vnd.sun.xml.draw sxd +application/vnd.sun.xml.draw.template std +application/vnd.sun.xml.impress sxi +application/vnd.sun.xml.impress.template sti +application/vnd.sun.xml.math sxm +application/vnd.sun.xml.writer sxw +application/vnd.sun.xml.writer.global sxg +application/vnd.sun.xml.writer.template stw +application/vnd.svd +application/vnd.swiftview-ics +application/vnd.symbian.install sis +application/vnd.tcpdump.pcap cap pcap +application/vnd.triscape.mxs +application/vnd.trueapp +application/vnd.truedoc +application/vnd.tve-trigger +application/vnd.ufdl +application/vnd.uplanet.alert +application/vnd.uplanet.alert-wbxml +application/vnd.uplanet.bearer-choice +application/vnd.uplanet.bearer-choice-wbxml +application/vnd.uplanet.cacheop +application/vnd.uplanet.cacheop-wbxml +application/vnd.uplanet.channel +application/vnd.uplanet.channel-wbxml +application/vnd.uplanet.list +application/vnd.uplanet.list-wbxml +application/vnd.uplanet.listcmd +application/vnd.uplanet.listcmd-wbxml +application/vnd.uplanet.signal +application/vnd.vcx +application/vnd.vectorworks +application/vnd.vidsoft.vidconference +application/vnd.visio vsd vst vsw vss +application/vnd.vividence.scriptfile +application/vnd.wap.sic +application/vnd.wap.slc +application/vnd.wap.wbxml wbxml +application/vnd.wap.wmlc wmlc +application/vnd.wap.wmlscriptc wmlsc +application/vnd.webturbo +application/vnd.wordperfect wpd +application/vnd.wordperfect5.1 wp5 +application/vnd.wrq-hp3000-labelled +application/vnd.wt.stf +application/vnd.xara +application/vnd.xfdl +application/vnd.yellowriver-custom-menu +application/zlib +application/x-123 wk +application/x-7z-compressed 7z +application/x-abiword abw +application/x-apple-diskimage dmg +application/x-bcpio bcpio +application/x-bittorrent torrent +application/x-cab cab +application/x-cbr cbr +application/x-cbz cbz +application/x-cdf cdf cda +application/x-cdlink vcd +application/x-chess-pgn pgn +application/x-comsol mph +application/x-core +application/x-cpio cpio +application/x-csh csh +application/x-debian-package deb udeb +application/x-director dcr dir dxr +application/x-dms dms +application/x-doom wad +application/x-dvi dvi +application/x-executable +application/x-font pfa pfb gsf +application/x-font-pcf pcf pcf.Z +application/x-freemind mm +application/x-futuresplash spl +application/x-ganttproject gan +application/x-gnumeric gnumeric +application/x-go-sgf sgf +application/x-graphing-calculator gcf +application/x-gtar gtar +application/x-gtar-compressed tgz taz +application/x-hdf hdf +#application/x-httpd-eruby rhtml +#application/x-httpd-php phtml pht php +#application/x-httpd-php-source phps +#application/x-httpd-php3 php3 +#application/x-httpd-php3-preprocessed php3p +#application/x-httpd-php4 php4 +#application/x-httpd-php5 php5 +application/x-hwp hwp +application/x-ica ica +application/x-info info +application/x-internet-signup ins isp +application/x-iphone iii +application/x-iso9660-image iso +application/x-jam jam +application/x-java-applet +application/x-java-bean +application/x-java-jnlp-file jnlp +application/x-jmol jmz +application/x-kchart chrt +application/x-kdelnk +application/x-killustrator kil +application/x-koan skp skd skt skm +application/x-kpresenter kpr kpt +application/x-kspread ksp +application/x-kword kwd kwt +application/x-latex latex +application/x-lha lha +application/x-lyx lyx +application/x-lzh lzh +application/x-lzx lzx +application/x-maker frm maker frame fm fb book fbdoc +application/x-mif mif +application/x-mpegURL m3u8 +application/x-ms-application application +application/x-ms-manifest manifest +application/x-ms-wmd wmd +application/x-ms-wmz wmz +application/x-msdos-program com exe bat dll +application/x-msi msi +application/x-netcdf nc +application/x-ns-proxy-autoconfig pac +application/x-nwc nwc +application/x-object o +application/x-oz-application oza +application/x-pkcs7-certreqresp p7r +application/x-pkcs7-crl crl +application/x-python-code pyc pyo +application/x-qgis qgs shp shx +application/x-quicktimeplayer qtl +application/x-rdp rdp +application/x-redhat-package-manager rpm +application/x-rss+xml rss +application/x-ruby rb +application/x-rx +application/x-scilab sci sce +application/x-scilab-xcos xcos +application/x-sh sh +application/x-shar shar +application/x-shellscript +application/x-shockwave-flash swf swfl +application/x-silverlight scr +application/x-sql sql +application/x-stuffit sit sitx +application/x-sv4cpio sv4cpio +application/x-sv4crc sv4crc +application/x-tar tar +application/x-tcl tcl +application/x-tex-gf gf +application/x-tex-pk pk +application/x-texinfo texinfo texi +application/x-trash ~ % bak old sik +application/x-troff t tr roff +application/x-troff-man man +application/x-troff-me me +application/x-troff-ms ms +application/x-ustar ustar +application/x-videolan +application/x-wais-source src +application/x-wingz wz +application/x-x509-ca-cert crt +application/x-xcf xcf +application/x-xfig fig +application/x-xpinstall xpi +application/x-xz xz + +audio/32kadpcm +audio/3gpp +audio/amr amr +audio/amr-wb awb +audio/annodex axa +audio/basic au snd +audio/csound csd orc sco +audio/flac flac +audio/g.722.1 +audio/l16 +audio/midi mid midi kar +audio/mp4a-latm +audio/mpa-robust +audio/mpeg mpga mpega mp2 mp3 m4a +audio/mpegurl m3u +audio/ogg oga ogg opus spx +audio/parityfec +audio/prs.sid sid +audio/telephone-event +audio/tone +audio/vnd.cisco.nse +audio/vnd.cns.anp1 +audio/vnd.cns.inf1 +audio/vnd.digital-winds +audio/vnd.everad.plj +audio/vnd.lucent.voice +audio/vnd.nortel.vbk +audio/vnd.nuera.ecelp4800 +audio/vnd.nuera.ecelp7470 +audio/vnd.nuera.ecelp9600 +audio/vnd.octel.sbc +audio/vnd.qcelp +audio/vnd.rhetorex.32kadpcm +audio/vnd.vmx.cvsd +audio/x-aiff aif aiff aifc +audio/x-gsm gsm +audio/x-mpegurl m3u +audio/x-ms-wma wma +audio/x-ms-wax wax +audio/x-pn-realaudio-plugin +audio/x-pn-realaudio ra rm ram +audio/x-realaudio ra +audio/x-scpls pls +audio/x-sd2 sd2 +audio/x-wav wav + +chemical/x-alchemy alc +chemical/x-cache cac cache +chemical/x-cache-csf csf +chemical/x-cactvs-binary cbin cascii ctab +chemical/x-cdx cdx +chemical/x-cerius cer +chemical/x-chem3d c3d +chemical/x-chemdraw chm +chemical/x-cif cif +chemical/x-cmdf cmdf +chemical/x-cml cml +chemical/x-compass cpa +chemical/x-crossfire bsd +chemical/x-csml csml csm +chemical/x-ctx ctx +chemical/x-cxf cxf cef +#chemical/x-daylight-smiles smi +chemical/x-embl-dl-nucleotide emb embl +chemical/x-galactic-spc spc +chemical/x-gamess-input inp gam gamin +chemical/x-gaussian-checkpoint fch fchk +chemical/x-gaussian-cube cub +chemical/x-gaussian-input gau gjc gjf +chemical/x-gaussian-log gal +chemical/x-gcg8-sequence gcg +chemical/x-genbank gen +chemical/x-hin hin +chemical/x-isostar istr ist +chemical/x-jcamp-dx jdx dx +chemical/x-kinemage kin +chemical/x-macmolecule mcm +chemical/x-macromodel-input mmd mmod +chemical/x-mdl-molfile mol +chemical/x-mdl-rdfile rd +chemical/x-mdl-rxnfile rxn +chemical/x-mdl-sdfile sd sdf +chemical/x-mdl-tgf tgf +#chemical/x-mif mif +chemical/x-mmcif mcif +chemical/x-mol2 mol2 +chemical/x-molconn-Z b +chemical/x-mopac-graph gpt +chemical/x-mopac-input mop mopcrt mpc zmt +chemical/x-mopac-out moo +chemical/x-mopac-vib mvb +chemical/x-ncbi-asn1 asn +chemical/x-ncbi-asn1-ascii prt ent +chemical/x-ncbi-asn1-binary val aso +chemical/x-ncbi-asn1-spec asn +chemical/x-pdb pdb ent +chemical/x-rosdal ros +chemical/x-swissprot sw +chemical/x-vamas-iso14976 vms +chemical/x-vmd vmd +chemical/x-xtel xtel +chemical/x-xyz xyz + +font/collection ttc +font/otf ttf otf +font/sfnt ttf otf +font/ttf ttf otf +font/woff woff +font/woff2 woff2 + +image/cgm +image/g3fax +image/gif gif +image/ief ief +image/jp2 jp2 jpg2 +image/jpeg jpeg jpg jpe +image/jpm jpm +image/jpx jpx jpf +image/naplps +image/pcx pcx +image/png png +image/prs.btif +image/prs.pti +image/svg+xml svg svgz +image/tiff tiff tif +image/vnd.cns.inf2 +image/vnd.djvu djvu djv +image/vnd.dwg +image/vnd.dxf +image/vnd.fastbidsheet +image/vnd.fpx +image/vnd.fst +image/vnd.fujixerox.edmics-mmr +image/vnd.fujixerox.edmics-rlc +image/vnd.microsoft.icon ico +image/vnd.mix +image/vnd.net-fpx +image/vnd.svf +image/vnd.wap.wbmp wbmp +image/vnd.xiff +image/x-canon-cr2 cr2 +image/x-canon-crw crw +image/x-cmu-raster ras +image/x-coreldraw cdr +image/x-coreldrawpattern pat +image/x-coreldrawtemplate cdt +image/x-corelphotopaint cpt +image/x-epson-erf erf +image/x-icon +image/x-jg art +image/x-jng jng +image/x-ms-bmp bmp +image/x-nikon-nef nef +image/x-olympus-orf orf +image/x-photoshop psd +image/x-portable-anymap pnm +image/x-portable-bitmap pbm +image/x-portable-graymap pgm +image/x-portable-pixmap ppm +image/x-rgb rgb +image/x-xbitmap xbm +image/x-xpixmap xpm +image/x-xwindowdump xwd + +inode/chardevice +inode/blockdevice +inode/directory-locked +inode/directory +inode/fifo +inode/socket + +message/delivery-status +message/disposition-notification +message/external-body +message/http +message/s-http +message/news +message/partial +message/rfc822 eml + +model/iges igs iges +model/mesh msh mesh silo +model/vnd.dwf +model/vnd.flatland.3dml +model/vnd.gdl +model/vnd.gs-gdl +model/vnd.gtw +model/vnd.mts +model/vnd.vtu +model/vrml wrl vrml +model/x3d+vrml x3dv +model/x3d+xml x3d +model/x3d+binary x3db + +multipart/alternative +multipart/appledouble +multipart/byteranges +multipart/digest +multipart/encrypted +multipart/form-data +multipart/header-set +multipart/mixed +multipart/parallel +multipart/related +multipart/report +multipart/signed +multipart/voice-message + +text/cache-manifest appcache +text/calendar ics icz +text/css css +text/csv csv +text/directory +text/english +text/enriched +text/h323 323 +text/html html htm shtml +text/iuls uls +text/mathml mml +text/markdown md markdown +text/parityfec +text/plain asc txt text pot brf srt +text/prs.lines.tag +text/rfc822-headers +text/richtext rtx +text/rtf +text/scriptlet sct wsc +text/t140 +text/texmacs tm +text/tab-separated-values tsv +text/turtle ttl +text/uri-list +text/vcard vcf vcard +text/vnd.abc +text/vnd.curl +text/vnd.debian.copyright +text/vnd.DMClientScript +text/vnd.flatland.3dml +text/vnd.fly +text/vnd.fmi.flexstor +text/vnd.in3d.3dml +text/vnd.in3d.spot +text/vnd.IPTC.NewsML +text/vnd.IPTC.NITF +text/vnd.latex-z +text/vnd.motorola.reflex +text/vnd.ms-mediapackage +text/vnd.sun.j2me.app-descriptor jad +text/vnd.wap.si +text/vnd.wap.sl +text/vnd.wap.wml wml +text/vnd.wap.wmlscript wmls +text/x-bibtex bib +text/x-boo boo +text/x-c++hdr h++ hpp hxx hh +text/x-c++src c++ cpp cxx cc +text/x-chdr h +text/x-component htc +text/x-crontab +text/x-csh csh +text/x-csrc c +text/x-dsrc d +text/x-diff diff patch +text/x-haskell hs +text/x-java java +text/x-lilypond ly +text/x-literate-haskell lhs +text/x-makefile +text/x-moc moc +text/x-pascal p pas +text/x-pcs-gcd gcd +text/x-perl pl pm +text/x-python py +text/x-scala scala +text/x-server-parsed-html +text/x-setext etx +text/x-sfv sfv +text/x-sh sh +text/x-tcl tcl tk +text/x-tex tex ltx sty cls +text/x-vcalendar vcs + +video/3gpp 3gp +video/annodex axv +video/dl dl +video/dv dif dv +video/fli fli +video/gl gl +video/mpeg mpeg mpg mpe +video/MP2T ts +video/mp4 mp4 +video/quicktime qt mov +video/mp4v-es +video/ogg ogv +video/parityfec +video/pointer +video/webm webm +video/vnd.fvt +video/vnd.motorola.video +video/vnd.motorola.videop +video/vnd.mpegurl mxu +video/vnd.mts +video/vnd.nokia.interleaved-multimedia +video/vnd.vivo +video/x-flv flv +video/x-la-asf lsf lsx +video/x-mng mng +video/x-ms-asf asf asx +video/x-ms-wm wm +video/x-ms-wmv wmv +video/x-ms-wmx wmx +video/x-ms-wvx wvx +video/x-msvideo avi +video/x-sgi-movie movie +video/x-matroska mpv mkv + +x-conference/x-cooltalk ice + +x-epoc/x-sisx-app sisx +x-world/x-vrml vrm vrml wrl
\ No newline at end of file diff --git a/tests/src/com/android/providers/media/IsolatedContext.java b/tests/src/com/android/providers/media/IsolatedContext.java index d0c1001c7..fa9a103ba 100644 --- a/tests/src/com/android/providers/media/IsolatedContext.java +++ b/tests/src/com/android/providers/media/IsolatedContext.java @@ -35,6 +35,7 @@ import androidx.annotation.VisibleForTesting; import com.android.providers.media.cloudproviders.CloudProviderPrimary; import com.android.providers.media.cloudproviders.FlakyCloudProvider; import com.android.providers.media.dao.FileRow; +import com.android.providers.media.flags.Flags; import com.android.providers.media.photopicker.PhotoPickerProvider; import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.util.FileUtils; @@ -63,6 +64,17 @@ public class IsolatedContext extends ContextWrapper { public IsolatedContext(Context base, String tag, boolean asFuseThread, UserHandle userHandle, ConfigStore configStore) { + this(base, tag, asFuseThread, userHandle, configStore, new MaliciousAppDetector(base)); + } + + public IsolatedContext(Context base, String tag, boolean asFuseThread, + MaliciousAppDetector maliciousAppDetector) { + this(base, tag, asFuseThread, base.getUser(), new TestConfigStore(), maliciousAppDetector); + } + + public IsolatedContext(Context base, String tag, boolean asFuseThread, + UserHandle userHandle, ConfigStore configStore, + MaliciousAppDetector maliciousAppDetector) { super(base); mDir = new File(base.getFilesDir(), tag); mDir.mkdirs(); @@ -71,7 +83,7 @@ public class IsolatedContext extends ContextWrapper { mResolver = new MockContentResolver(this); mUserHandle = userHandle; - mMediaProvider = getMockedMediaProvider(asFuseThread, configStore); + mMediaProvider = getMockedMediaProvider(asFuseThread, configStore, maliciousAppDetector); attachInfoAndAddProvider(base, mMediaProvider, MediaStore.AUTHORITY); MediaDocumentsProvider documentsProvider = new MediaDocumentsProvider(); @@ -98,7 +110,7 @@ public class IsolatedContext extends ContextWrapper { } private MediaProvider getMockedMediaProvider(boolean asFuseThread, - ConfigStore configStore) { + ConfigStore configStore, MaliciousAppDetector maliciousAppDetector) { return new MediaProvider() { @Override public boolean isFuseThread() { @@ -124,6 +136,22 @@ public class IsolatedContext extends ContextWrapper { protected void updateQuotaTypeForUri(@NonNull FileRow row) { return; } + + @Override + boolean shouldLockdownMediaStoreVersion() { + // TODO(b/370999570): Set to true once Baklava is in dev + return false; + } + + @Override + protected MaliciousAppDetector createMaliciousAppDetector() { + return maliciousAppDetector; + } + + @Override + protected boolean shouldCheckForMaliciousActivity() { + return Flags.enableMaliciousAppDetector(); + } }; } diff --git a/tests/src/com/android/providers/media/LocalUriMatcherTest.java b/tests/src/com/android/providers/media/LocalUriMatcherTest.java index aceaa9b45..04b5a288f 100644 --- a/tests/src/com/android/providers/media/LocalUriMatcherTest.java +++ b/tests/src/com/android/providers/media/LocalUriMatcherTest.java @@ -52,6 +52,8 @@ public class LocalUriMatcherTest { LocalUriMatcher.PICKER_GET_CONTENT_ID, assembleTestUri( new String[]{"picker_get_content", "0", "anything", "media", "anything"})); + assertMatchesPublic(LocalUriMatcher.PICKER_TRANSCODED_ID, assembleTestUri( + new String[]{"picker_transcoded", "0", "anything", "media", "anything"})); assertMatchesPublic(LocalUriMatcher.CLI, assembleTestUri(new String[] {"cli"})); diff --git a/tests/src/com/android/providers/media/MaliciousAppCheckerTest.java b/tests/src/com/android/providers/media/MaliciousAppCheckerTest.java new file mode 100644 index 000000000..6014bd60b --- /dev/null +++ b/tests/src/com/android/providers/media/MaliciousAppCheckerTest.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.Manifest; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.SystemClock; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; +import android.provider.MediaStore; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.SdkSuppress; +import androidx.test.runner.AndroidJUnit4; + +import com.android.providers.media.flags.Flags; + +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) +public class MaliciousAppCheckerTest { + private static Context sIsolatedContext; + private static ContentResolver sIsolatedResolver; + private static final int FILE_CREATION_THRESHOLD_LIMIT = 5; + private static final int FREQUENCY_OF_MALICIOUS_INSERTION_CHECK = 1; + private static MaliciousAppDetector sMaliciousAppDetector; + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + @BeforeClass + public static void setUpBeforeClass() { + InstrumentationRegistry.getInstrumentation().getUiAutomation() + .adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE, + Manifest.permission.READ_COMPAT_CHANGE_CONFIG, + Manifest.permission.READ_DEVICE_CONFIG, + // Adding this to use getUserHandles() api of UserManagerService which + // requires either MANAGE_USERS or CREATE_USERS. Since shell does not have + // MANAGER_USERS permissions, using CREATE_USERS in test. This works with + // MANAGE_USERS permission for MediaProvider module. + Manifest.permission.CREATE_USERS, + Manifest.permission.INTERACT_ACROSS_USERS); + } + + @Before + public void setUp() { + resetIsolatedContext(); + sMaliciousAppDetector = new MaliciousAppDetector(sIsolatedContext, + FILE_CREATION_THRESHOLD_LIMIT, FREQUENCY_OF_MALICIOUS_INSERTION_CHECK); + } + + @AfterClass + public static void tearDown() { + InstrumentationRegistry.getInstrumentation() + .getUiAutomation().dropShellPermissionIdentity(); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_MALICIOUS_APP_DETECTOR) + public void testCannotCreateFileLimitExceeded() throws Exception { + resetIsolatedContext(); + sMaliciousAppDetector.clearSharedPref(); + + createTempFilesInDownloadFolder(FILE_CREATION_THRESHOLD_LIMIT); + // add sleep to wait for the background process + SystemClock.sleep(1000); + int uid = android.os.Process.myUid(); + boolean isAllowedToCreateFile = sMaliciousAppDetector.isAppAllowedToCreateFiles(uid); + + assertFalse("File should not be allowed to create after limit exceeded", + isAllowedToCreateFile); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_MALICIOUS_APP_DETECTOR) + public void testCreateFileWithinLimit() throws Exception { + resetIsolatedContext(); + sMaliciousAppDetector.clearSharedPref(); + + // create files less than the threshold limit + createTempFilesInDownloadFolder(FILE_CREATION_THRESHOLD_LIMIT - 1); + // add sleep to wait for the background process + SystemClock.sleep(1000); + int uid = android.os.Process.myUid(); + boolean isAllowedToCreateFile = sMaliciousAppDetector.isAppAllowedToCreateFiles(uid); + + assertTrue("File should be allowed to create within limit", isAllowedToCreateFile); + } + + private void createTempFilesInDownloadFolder(int numberOfFilesToCreate) { + final Uri downloadUri = MediaStore.Downloads + .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + for (int index = 0; index < numberOfFilesToCreate; index++) { + final ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.DISPLAY_NAME, "test_" + index + ".txt"); + values.put(MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME, + sIsolatedContext.getPackageName()); + sIsolatedResolver.insert(downloadUri, values); + } + } + + private static void resetIsolatedContext() { + if (sIsolatedResolver != null) { + // This is necessary, we wait for all unfinished tasks to finish before we create a + // new IsolatedContext. + MediaStore.waitForIdle(sIsolatedResolver); + } + + Context context = InstrumentationRegistry.getTargetContext(); + sIsolatedContext = new IsolatedContext(context, "modern", /*asFuseThread*/ false, + sMaliciousAppDetector); + sIsolatedResolver = sIsolatedContext.getContentResolver(); + } +} diff --git a/tests/src/com/android/providers/media/MediaIndexingDatabaseOperationsTest.java b/tests/src/com/android/providers/media/MediaIndexingDatabaseOperationsTest.java new file mode 100644 index 000000000..8048ec2c2 --- /dev/null +++ b/tests/src/com/android/providers/media/MediaIndexingDatabaseOperationsTest.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.Manifest; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.providers.media.search.SearchUtilConstants; +import com.android.providers.media.search.exceptions.SqliteCheckedException; + +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class MediaIndexingDatabaseOperationsTest { + + private Context mContext; + private IsolatedContext mIsolatedContext; + private MediaIndexingDatabaseOperationsTestExtension mIndexingDatabaseOperations; + private DatabaseHelper mDatabaseHelper; + private SearchTestingUtils mSearchTestingUtils; + private SearchUtilConstants mSearchUtilConstants; + + @BeforeClass + public static void setUpBeforeClass() { + // Permissions needed to insert files via the content resolver + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity( + Manifest.permission.LOG_COMPAT_CHANGE, + Manifest.permission.READ_COMPAT_CHANGE_CONFIG, + Manifest.permission.WRITE_MEDIA_STORAGE); + } + + @AfterClass + public static void tearDownAfterClass() { + InstrumentationRegistry.getInstrumentation() + .getUiAutomation().dropShellPermissionIdentity(); + } + + @Before + public void setup() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + mIsolatedContext = new IsolatedContext(mContext, + /* tag */"MediaItemsIndexingDatabaseHelperTest", /*asFuseThread*/ false); + mIndexingDatabaseOperations = new MediaIndexingDatabaseOperationsTestExtension( + mIsolatedContext); + mDatabaseHelper = mIsolatedContext.getExternalDatabase(); + mSearchTestingUtils = new SearchTestingUtils(mDatabaseHelper); + mSearchUtilConstants = new SearchUtilConstants(); + } + + @Test + public void testInsertMediaItemIntoMediaIndexingTable() + throws SqliteCheckedException { + String mediaIdToInsert = "1000"; + + // Insert the mediaItemId into the status table + ContentValues insertValues = new ContentValues(); + insertValues.put(mSearchUtilConstants.MEDIA_ID_COLUMN, mediaIdToInsert); + + mIndexingDatabaseOperations.insertIntoMediaStatusTable( + mDatabaseHelper, List.of(insertValues)); + + // Assert that the value was inserted and its processing status to be 0 + Cursor cursor = mSearchTestingUtils.getMediaItemDataFromStatusTable( + Long.parseLong(mediaIdToInsert)); + if (cursor.moveToFirst()) { + int metadataStatusIndex = cursor.getColumnIndexOrThrow( + mSearchUtilConstants.METADATA_PROCESSING_STATUS_COLUMN); + int labelStatusIndex = cursor.getColumnIndexOrThrow( + mSearchUtilConstants.LABEL_PROCESSING_STATUS_COLUMN); + int ocrStatusIndex = cursor.getColumnIndexOrThrow( + mSearchUtilConstants.OCR_PROCESSING_STATUS_COLUMN); + int locationStatusIndex = cursor.getColumnIndexOrThrow( + mSearchUtilConstants.LOCATION_PROCESSING_STATUS_COLUMN); + int metadataStatus = cursor.getInt(metadataStatusIndex); + int locationStatus = cursor.getInt(locationStatusIndex); + int ocrStatus = cursor.getInt(ocrStatusIndex); + int labelStatus = cursor.getInt(labelStatusIndex); + assertEquals(/* expectedProcessingStatus */ 0, metadataStatus); + assertEquals(/* expectedProcessingStatus */ 0, locationStatus); + assertEquals(/* expectedProcessingStatus */ 0, ocrStatus); + assertEquals(/* expectedProcessingStatus */ 0, labelStatus); + } + } + + @Test + public void testQueryExternalDatabaseTable() throws SqliteCheckedException { + String mediaIdToQuery = "1000"; + ContentValues insertValues = new ContentValues(); + insertValues.put(mSearchUtilConstants.MEDIA_ID_COLUMN, mediaIdToQuery); + + String testSelection = mSearchUtilConstants.MEDIA_ID_COLUMN + " = ?"; + String[] testSelectionArgs = new String[]{ mediaIdToQuery }; + String[] columns = new String[] { mSearchUtilConstants.METADATA_PROCESSING_STATUS_COLUMN }; + // Insert the mediaItemId into the status table + mIndexingDatabaseOperations.insertIntoMediaStatusTable( + mDatabaseHelper, List.of(insertValues)); + + // Query media indexing table + Cursor cursor = mIndexingDatabaseOperations.queryExternalDatabaseTable( + mDatabaseHelper, /* tableName */ mSearchUtilConstants.MEDIA_STATUS_TABLE, + columns, testSelection, testSelectionArgs, null, null); + + // Assert on the retrieved cursor + assertNotNull("Cursor was found to be null", cursor); + assertEquals("Expected cursor size did not match", + /* expected cursorSize */ 1, cursor.getCount()); + // Assert the status to be 0 + if (cursor.moveToFirst()) { + assertEquals("testId processing status was expected to be 0", + /* expected processingStatus */ 0, cursor.getInt(0)); + } + } + + @Test + public void testUpdateStatusTableValues() throws SqliteCheckedException { + String mediaIdToUpdate = "1000"; + ContentValues insertValues = new ContentValues(); + insertValues.put(mSearchUtilConstants.MEDIA_ID_COLUMN, mediaIdToUpdate); + + // Insert the mediaItemId into the status table + mIndexingDatabaseOperations.insertIntoMediaStatusTable( + mDatabaseHelper, List.of(insertValues)); + + // Update the metadata indexing status column + ContentValues updateValues = new ContentValues(); + updateValues.put(mSearchUtilConstants.METADATA_PROCESSING_STATUS_COLUMN, 1); + String[] updateIds = new String[] { mediaIdToUpdate }; + mIndexingDatabaseOperations.updateStatusTableValues( + mDatabaseHelper, updateIds, updateValues); + + // Assert that the value was updated to 1 + Cursor cursor = mSearchTestingUtils.getMediaItemDataFromStatusTable( + Long.parseLong(mediaIdToUpdate)); + if (cursor.moveToFirst()) { + int metadataStatusIndex = cursor.getColumnIndexOrThrow( + mSearchUtilConstants.METADATA_PROCESSING_STATUS_COLUMN); + int metadataStatus = cursor.getInt(metadataStatusIndex); + assertEquals(/* expected value after update */ 1, metadataStatus); + } + } + + @Test + public void testDeleteMediaItemFromMediaIndexingTable() + throws SqliteCheckedException { + String mediaItemToDelete = "1000"; + String testSelection = mSearchUtilConstants.MEDIA_ID_COLUMN + " = ?"; + String[] testSelectionArgs = new String[]{ mediaItemToDelete }; + String[] columns = new String[] { mSearchUtilConstants.METADATA_PROCESSING_STATUS_COLUMN }; + ContentValues insertValues = new ContentValues(); + insertValues.put(mSearchUtilConstants.MEDIA_ID_COLUMN, mediaItemToDelete); + + // Insert the mediaItemId into the status table + mIndexingDatabaseOperations.insertIntoMediaStatusTable( + mDatabaseHelper, List.of(insertValues)); + + // Delete the item from the indexing table + mIndexingDatabaseOperations.deleteMediaItemFromMediaStatusTable( + mDatabaseHelper, mediaItemToDelete); + + // Assert that the item does not exist in the indexing table + Cursor cursor = mIndexingDatabaseOperations.queryExternalDatabaseTable( + mDatabaseHelper, /* tableName */ mSearchUtilConstants.MEDIA_STATUS_TABLE, + columns, testSelection, testSelectionArgs, null, null); + assertEquals(/* expected cursorSize */ 0, cursor.getCount()); + } + + @Test + public void testGetDatabaseHelper() { + DatabaseHelper testHelper = mIndexingDatabaseOperations.getDatabaseHelper(mContext); + assertNotNull(testHelper); + assertTrue(testHelper.isExternal()); + } + + + private static class MediaIndexingDatabaseOperationsTestExtension extends + MediaIndexingDatabaseOperations { + IsolatedContext mIsolatedContext; + + MediaIndexingDatabaseOperationsTestExtension(IsolatedContext context) { + mIsolatedContext = context; + } + + @Override + public DatabaseHelper getDatabaseHelper(Context context) { + return mIsolatedContext.getExternalDatabase(); + } + + } +} diff --git a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java index 8cbdb9c7b..ab3c4969f 100644 --- a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java +++ b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java @@ -31,6 +31,7 @@ import android.content.pm.PackageManager; import android.database.Cursor; import android.os.Bundle; import android.os.Environment; +import android.os.UserHandle; import android.provider.MediaStore; import android.system.OsConstants; import android.util.Log; @@ -52,6 +53,7 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InterruptedIOException; import java.util.Arrays; +import java.util.Locale; /** * Unit tests for {@link MediaProvider} forFuse methods. {@code CtsScopedStorageHostTest} (and @@ -205,15 +207,20 @@ public class MediaProviderForFuseTest { @Test public void testRenameDirectory_WhenParentDirectoryIsHidden() throws Exception { // Create parent dir with nomedia file - final File parent = new File(sTestDir, "hidden" + System.nanoTime()); - parent.mkdirs(); - createNomediaFile(parent); + // Choosing the base dir to be a public directory so the file can be created by the test + // app context without need of shell or root privilege. + File parentDir = new File(Environment.getExternalStorageDirectory(), + Environment.DIRECTORY_DOWNLOADS); + File dir = new File(parentDir, "hidden" + System.nanoTime()); + dir.mkdirs(); + createNomediaFile(dir); + // Create dir in hidden parent dir - File file = createSubdirWithOneFile(parent); + File file = createSubdirWithOneFile(dir); File oldDir = file.getParentFile(); // Rename dir within hidden parent. - final File renamedDir = new File(parent, "renamed" + System.nanoTime()); + final File renamedDir = new File(dir, "renamed" + System.nanoTime()); Truth.assertThat(sMediaProvider.renameForFuse( oldDir.getPath(), renamedDir.getPath(), sTestUid)).isEqualTo(0); @@ -237,8 +244,14 @@ public class MediaProviderForFuseTest { // the process that is, mContext.checkUriPermission and should throw a security // exception. sMediaProvider.onFileLookupForFuse( - "/storage/emulated/0/.transforms/synthetic/picker/0/com.android.providers" - + ".media.photopicker/media/1000000.jpg", sTestUid /* uid */, + String.format( + Locale.ROOT, + "/storage/emulated/%d/.transforms/synthetic/picker/%d/com.android.providers" + + ".media.photopicker/media/1000000.jpg", + UserHandle.myUserId(), + UserHandle.myUserId() + ), + sTestUid /* uid */, 0 /* tid */); fail("This test should throw a security exception"); } catch (SecurityException se) { @@ -248,7 +261,7 @@ public class MediaProviderForFuseTest { private @NonNull File createNomediaFile(@NonNull File dir) throws IOException { final File nomediaFile = new File(dir, ".nomedia"); - executeShellCommand("touch " + nomediaFile.getAbsolutePath()); + nomediaFile.createNewFile(); Truth.assertWithMessage("cannot create nomedia file: " + nomediaFile.getAbsolutePath()) .that(nomediaFile.exists()) .isTrue(); diff --git a/tests/src/com/android/providers/media/MediaProviderTest.java b/tests/src/com/android/providers/media/MediaProviderTest.java index 1a07fbda0..21cb56e48 100644 --- a/tests/src/com/android/providers/media/MediaProviderTest.java +++ b/tests/src/com/android/providers/media/MediaProviderTest.java @@ -16,8 +16,11 @@ package com.android.providers.media; +import static android.content.ContentResolver.QUERY_ARG_SQL_GROUP_BY; +import static android.content.ContentResolver.QUERY_ARG_SQL_HAVING; import static android.provider.MediaStore.getGeneration; +import static com.android.providers.media.DatabaseHelper.EXTERNAL_DATABASE_NAME; import static com.android.providers.media.scan.MediaScannerTest.stage; import static com.android.providers.media.util.FileUtils.extractDisplayName; import static com.android.providers.media.util.FileUtils.extractRelativePath; @@ -46,6 +49,7 @@ import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.pm.ProviderInfo; import android.content.res.AssetFileDescriptor; @@ -59,6 +63,11 @@ import android.os.Environment; import android.os.RemoteException; import android.os.UserHandle; import android.os.UserManager; +import android.platform.test.annotations.RequiresFlagsDisabled; +import android.platform.test.annotations.RequiresFlagsEnabled; +import android.platform.test.flag.junit.CheckFlagsRule; +import android.platform.test.flag.junit.DeviceFlagsValueProvider; +import android.preference.PreferenceManager; import android.provider.MediaStore; import android.provider.MediaStore.Audio.AudioColumns; import android.provider.MediaStore.Files.FileColumns; @@ -79,19 +88,22 @@ import androidx.test.runner.AndroidJUnit4; import com.android.providers.media.MediaProvider.FallbackException; import com.android.providers.media.MediaProvider.VolumeArgumentException; import com.android.providers.media.MediaProvider.VolumeNotFoundException; +import com.android.providers.media.flags.Flags; import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.photopicker.data.ItemsProvider; +import com.android.providers.media.scan.MediaScanner; +import com.android.providers.media.scan.ModernMediaScanner; import com.android.providers.media.util.FileUtils; import com.android.providers.media.util.FileUtilsTest; import com.android.providers.media.util.SQLiteQueryBuilder; import com.android.providers.media.util.UserCache; -import org.junit.AfterClass; +import org.junit.After; import org.junit.Assert; import org.junit.Assume; import org.junit.Before; -import org.junit.BeforeClass; import org.junit.Ignore; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; @@ -107,10 +119,15 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Optional; import java.util.regex.Pattern; +import java.util.stream.Collectors; @RunWith(AndroidJUnit4.class) public class MediaProviderTest { + @Rule + public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); + static final String TAG = "MediaProviderTest"; // The test app without permissions @@ -122,8 +139,8 @@ public class MediaProviderTest { private static Context sContext; private static ContentResolver sIsolatedResolver; - @BeforeClass - public static void setUpBeforeClass() { + @Before + public void setUp() { InstrumentationRegistry.getInstrumentation().getUiAutomation() .adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE, Manifest.permission.READ_COMPAT_CHANGE_CONFIG, @@ -134,15 +151,11 @@ public class MediaProviderTest { // MANAGE_USERS permission for MediaProvider module. Manifest.permission.CREATE_USERS, Manifest.permission.INTERACT_ACROSS_USERS); - } - - @Before - public void setUp() { resetIsolatedContext(); } - @AfterClass - public static void tearDown() { + @After + public void tearDown() { InstrumentationRegistry.getInstrumentation() .getUiAutomation().dropShellPermissionIdentity(); } @@ -651,13 +664,19 @@ public class MediaProviderTest { values); final ContentValues newValues = new ContentValues(); - newValues.put(MediaStore.MediaColumns.DATA, "/storage/emulated/0/../../../data/media/"); + newValues.put( + MediaStore.MediaColumns.DATA, + String.format(Locale.ROOT, + "/storage/emulated/%d/../../../data/media/", + UserHandle.myUserId())); IllegalArgumentException illegalArgumentException = Assert.assertThrows( IllegalArgumentException.class, () -> sIsolatedResolver.update(uri, newValues, null)); assertThat(illegalArgumentException).hasMessageThat().contains( - "Requested path /data/media doesn't appear under [/storage/emulated/0]"); + String.format(Locale.ROOT, + "Requested path /data/media doesn't appear under [/storage/emulated/%d]", + UserHandle.myUserId())); } /** @@ -904,6 +923,276 @@ public class MediaProviderTest { buildFile(uri, null, "", "")); } + private void setSharedPreference(String preference, int value) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences( + sIsolatedContext); + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(preference, value); + editor.commit(); + } + + + @Test + @RequiresFlagsEnabled(Flags.FLAG_ENABLE_EXCLUSION_LIST_FOR_DEFAULT_FOLDERS) + public void testEnsureDefaultFolders_EmptyExclusionList() throws Exception { + // Put the fake "default" folders in Documents as they can easily be created and + // deleted here. + String[] defaultFolderList = + new String[]{"Documents/A", "Documents/B", "Documents/C", "Documents/D"}; + final MediaProvider provider = new MediaProvider() { + @Override + protected String[] getDefaultFolderNames() { + return defaultFolderList; + } + + @Override + protected List<String> getFoldersToSkipInDefaultCreation() { + // Set an empty exclusion list. + return Arrays.asList(); + } + + @Override + protected void storageNativeBootPropertyChangeListener() { + // Ignore this as test app cannot read device config + } + }; + + // Get the external primary volume. + final ProviderInfo info = sIsolatedContext.getPackageManager() + .resolveContentProvider(MediaStore.AUTHORITY, PackageManager.GET_META_DATA); + provider.attachInfo(sIsolatedContext, info); + MediaVolume externalPrimary = provider.getVolume(MediaStore.VOLUME_EXTERNAL_PRIMARY); + + // Set this preference to ensure that the default folders are actually created. + setSharedPreference("created_default_folders_" + externalPrimary.getId(), 0); + + // Make sure none of the folders exist already. + for (String folderName : defaultFolderList) { + final File folder = new File(externalPrimary.getPath(), folderName); + if (folder.exists()) { + assertTrue(folder.delete()); + } + } + + // Create the default folders for the external primary volume. + Optional<DatabaseHelper> maybeDatabaseHelper = provider.getDatabaseHelper( + EXTERNAL_DATABASE_NAME); + assertTrue(maybeDatabaseHelper.isPresent()); + provider.ensureDefaultFolders(externalPrimary, + maybeDatabaseHelper.get().getWritableDatabaseForTest()); + + // Make sure all of the folders were created. + for (String folderName : defaultFolderList) { + final File folder = new File(externalPrimary.getPath(), folderName); + try { + assertTrue(folder.exists()); + } finally { + // Clean up. + folder.delete(); + } + } + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_ENABLE_EXCLUSION_LIST_FOR_DEFAULT_FOLDERS) + public void testEnsureDefaultFolders_WithInvalidExclusionList() throws Exception { + // Put the fake "default" folders in Documents as they can easily be created and + // deleted here. + String[] defaultFolderList = + new String[]{"Documents/FolderA", "Documents/FolderB", "Documents/FolderC", + "Documents/FolderD"}; + // The exclusion list contains more items than there are on the default list. It should + // be ignored. + List<String> exclusionList = Arrays.asList("Documents/FolderA", "Documents/FolderB", + "Documents/FolderC", + "Documents/FolderD", "Documents/FolderE"); + final MediaProvider provider = new MediaProvider() { + @Override + protected String[] getDefaultFolderNames() { + return defaultFolderList; + } + + @Override + protected List<String> getFoldersToSkipInDefaultCreation() { + return exclusionList; + } + + @Override + protected void storageNativeBootPropertyChangeListener() { + // Ignore this as test app cannot read device config + } + }; + + // Get the external primary volume. + final ProviderInfo info = sIsolatedContext.getPackageManager() + .resolveContentProvider(MediaStore.AUTHORITY, PackageManager.GET_META_DATA); + provider.attachInfo(sIsolatedContext, info); + MediaVolume externalPrimary = provider.getVolume(MediaStore.VOLUME_EXTERNAL_PRIMARY); + + // Set this preference to ensure that the default folders are actually created. + setSharedPreference("created_default_folders_" + externalPrimary.getId(), 0); + + // Make sure none of the folders exist already. + for (String folderName : defaultFolderList) { + final File folder = new File(externalPrimary.getPath(), folderName); + if (folder.exists()) { + assertTrue(folder.delete()); + } + } + + // Create the default folders for the external primary volume. + Optional<DatabaseHelper> maybeDatabaseHelper = provider.getDatabaseHelper( + EXTERNAL_DATABASE_NAME); + assertTrue(maybeDatabaseHelper.isPresent()); + provider.ensureDefaultFolders(externalPrimary, + maybeDatabaseHelper.get().getWritableDatabaseForTest()); + + // Make sure all of the folders were created. + for (String folderName : defaultFolderList) { + final File folder = new File(externalPrimary.getPath(), folderName); + try { + assertTrue(folder.exists()); + } finally { + // Clean up. + folder.delete(); + } + } + } + + @Test + @RequiresFlagsDisabled(Flags.FLAG_ENABLE_EXCLUSION_LIST_FOR_DEFAULT_FOLDERS) + public void testEnsureDefaultFolders_FlagDisabled() throws Exception { + // Put the fake "default" folders in Documents as they can easily be created and + // deleted here. + String[] defaultFolderList = + new String[]{"Documents/FolderA", "Documents/FolderB", "Documents/FolderC", + "Documents/FolderD"}; + List<String> exclusionList = Arrays.asList("Documents/FolderA", "Documents/FolderC"); + final MediaProvider provider = new MediaProvider() { + @Override + protected String[] getDefaultFolderNames() { + return defaultFolderList; + } + + @Override + protected List<String> getFoldersToSkipInDefaultCreation() { + // This should be ignored as the flag is disabled. + return exclusionList; + } + + @Override + protected void storageNativeBootPropertyChangeListener() { + // Ignore this as test app cannot read device config + } + }; + + // Get the external primary volume. + final ProviderInfo info = sIsolatedContext.getPackageManager() + .resolveContentProvider(MediaStore.AUTHORITY, PackageManager.GET_META_DATA); + provider.attachInfo(sIsolatedContext, info); + MediaVolume externalPrimary = provider.getVolume(MediaStore.VOLUME_EXTERNAL_PRIMARY); + + // Set this preference to ensure that the default folders are actually created. + setSharedPreference("created_default_folders_" + externalPrimary.getId(), 0); + + // Make sure none of the folders exist already. + for (String folderName : defaultFolderList) { + final File folder = new File(externalPrimary.getPath(), folderName); + if (folder.exists()) { + assertTrue(folder.delete()); + } + } + + // Create the default folders for the external primary volume. + Optional<DatabaseHelper> maybeDatabaseHelper = provider.getDatabaseHelper( + EXTERNAL_DATABASE_NAME); + assertTrue(maybeDatabaseHelper.isPresent()); + provider.ensureDefaultFolders(externalPrimary, + maybeDatabaseHelper.get().getWritableDatabaseForTest()); + + // Make sure all of the folders were created. + for (String folderName : defaultFolderList) { + final File folder = new File(externalPrimary.getPath(), folderName); + try { + assertTrue(folder.exists()); + } finally { + // Clean up. + folder.delete(); + } + } + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_ENABLE_EXCLUSION_LIST_FOR_DEFAULT_FOLDERS) + public void testEnsureDefaultFolders_WithExclusionList() throws Exception { + // Put the fake "default" folders in Documents as they can easily be created and + // deleted here. + String[] defaultFolderList = + new String[]{"Documents/FolderA", "Documents/FolderB", "Documents/FolderC", + "Documents/FolderD"}; + // The exclusion list is case insensitive. + List<String> exclusionList = Arrays.asList("Documents/foldera", "Documents/FOLDERC"); + final MediaProvider provider = new MediaProvider() { + @Override + protected String[] getDefaultFolderNames() { + return defaultFolderList; + } + + @Override + protected List<String> getFoldersToSkipInDefaultCreation() { + return exclusionList; + } + + @Override + protected void storageNativeBootPropertyChangeListener() { + // Ignore this as test app cannot read device config + } + }; + + // Get the external primary volume. + final ProviderInfo info = sIsolatedContext.getPackageManager() + .resolveContentProvider(MediaStore.AUTHORITY, PackageManager.GET_META_DATA); + provider.attachInfo(sIsolatedContext, info); + MediaVolume externalPrimary = provider.getVolume(MediaStore.VOLUME_EXTERNAL_PRIMARY); + + // Set this preference to ensure that the default folders are actually created. + setSharedPreference("created_default_folders_" + externalPrimary.getId(), 0); + + // Make sure none of the folders exist already. + for (String folderName : defaultFolderList) { + final File folder = new File(externalPrimary.getPath(), folderName); + if (folder.exists()) { + assertTrue(folder.delete()); + } + } + + // Create the default folders for the external primary volume. + Optional<DatabaseHelper> maybeDatabaseHelper = provider.getDatabaseHelper( + EXTERNAL_DATABASE_NAME); + assertTrue(maybeDatabaseHelper.isPresent()); + provider.ensureDefaultFolders(externalPrimary, + maybeDatabaseHelper.get().getWritableDatabaseForTest()); + + // Make sure that the folders on exclusion list were not created. + List<String> exclusionListCaseInsensitive = exclusionList.stream().map( + String::toLowerCase).collect( + Collectors.toList()); + for (String folderName : defaultFolderList) { + final File folder = new File(externalPrimary.getPath(), folderName); + try { + if (exclusionListCaseInsensitive.contains( + folderName.toLowerCase(Locale.ROOT))) { + assertFalse(folder.exists()); + } else { + assertTrue(folder.exists()); + } + } finally { + // Clean up. + folder.delete(); + } + } + } + @Test public void testEnsureFileColumns_InvalidMimeType_targetSdkQ() throws Exception { final MediaProvider provider = new MediaProvider() { @@ -1030,7 +1319,10 @@ public class MediaProviderTest { final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); final ContentValues reverse = new ContentValues(); reverse.put(MediaColumns.DATA, - "/storage/emulated/0/DCIM/My Vacation/.pending-1577836800-IMG1024.JPG"); + String.format( + Locale.ROOT, + "/storage/emulated/%d/DCIM/My Vacation/.pending-1577836800-IMG1024.JPG", + UserHandle.myUserId())); ensureFileColumns(uri, reverse); assertEquals("DCIM/My Vacation/", reverse.getAsString(MediaColumns.RELATIVE_PATH)); @@ -1078,7 +1370,10 @@ public class MediaProviderTest { final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); final ContentValues reverse = new ContentValues(); reverse.put(MediaColumns.DATA, - "/storage/emulated/0/DCIM/My Vacation/.trashed-1577836800-IMG1024.JPG"); + String.format( + Locale.ROOT, + "/storage/emulated/%d/DCIM/My Vacation/.trashed-1577836800-IMG1024.JPG", + UserHandle.myUserId())); ensureFileColumns(uri, reverse); assertEquals("DCIM/My Vacation/", reverse.getAsString(MediaColumns.RELATIVE_PATH)); @@ -1943,6 +2238,148 @@ public class MediaProviderTest { } + @Test + @RequiresFlagsEnabled(Flags.FLAG_INDEX_MEDIA_LATITUDE_LONGITUDE) + public void testQueryingMediaGeolocationDataInProjectionShouldReturnNull() throws Exception { + // Check with both upper and lower case column names + String[][] projections = new String[][] { + new String[] { + ImageColumns.DISPLAY_NAME, + ImageColumns.LATITUDE, + ImageColumns.LONGITUDE + }, + new String[] { + ImageColumns.DISPLAY_NAME, + "LATITUDE", + "LONGITUDE" + }, + new String[] { + ImageColumns.DISPLAY_NAME, + ImageColumns.LATITUDE + " AS LAT", + ImageColumns.LONGITUDE + " AS LONG" + } + }; + + for (int i = 0; i < projections.length; i++) { + String[] projection = projections[i]; + String testFileName = "test_file"; + final File downloads = new File(Environment.getExternalStorageDirectory(), + Environment.DIRECTORY_DOWNLOADS); + File file = stage(R.raw.lg_g4_iso_800_jpg, new File(downloads, testFileName)); + ModernMediaScanner modernMediaScanner = new ModernMediaScanner(sIsolatedContext, + new TestConfigStore()); + Uri testFileUri = modernMediaScanner.scanFile(file, MediaScanner.REASON_UNKNOWN); + try (Cursor cursor = sIsolatedContext.getContentResolver() + .query(testFileUri, projection, null, null, null);) { + assertNotNull(cursor); + int nameIndex = cursor.getColumnIndex(ImageColumns.DISPLAY_NAME); + int latitudeIndex = cursor.getColumnIndex(ImageColumns.LATITUDE); + int longitudeIndex = cursor.getColumnIndex(ImageColumns.LONGITUDE); + + assertThat(cursor.getCount()).isEqualTo(1); + cursor.moveToFirst(); + // Assert name column accessed is non-null and valid + assertTrue(cursor.getString(nameIndex).contains(testFileName)); + // Geolocation data fields should be NULL + assertTrue("Latitude is not null", cursor.isNull(latitudeIndex)); + assertTrue("Longitude is not null", cursor.isNull(longitudeIndex)); + } finally { + // Cleanup + file.delete(); + } + } + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_INDEX_MEDIA_LATITUDE_LONGITUDE) + public void testQueryingMediaGeolocationDataInSelectionShouldReturnEmptyCursor() + throws Exception { + final File downloads = new File(Environment.getExternalStorageDirectory(), + Environment.DIRECTORY_DOWNLOADS); + File file = stage(R.raw.lg_g4_iso_800_jpg, new File(downloads, "test")); + ModernMediaScanner modernMediaScanner = new ModernMediaScanner(sIsolatedContext, + new TestConfigStore()); + Uri testFileUri = modernMediaScanner.scanFile(file, MediaScanner.REASON_UNKNOWN); + + String[] projection = new String[] { + ImageColumns._ID, + ImageColumns.DISPLAY_NAME + }; + String selection = ImageColumns.LATITUDE + " = ?"; + String[] selectionArgs = new String[] { "67.8" }; + try (Cursor cursor = sIsolatedContext.getContentResolver() + .query(testFileUri, projection, selection, selectionArgs, null);) { + assertNotNull(cursor); + // Should no return any results + assertThat(cursor.getCount()).isEqualTo(0); + } finally { + // Clean up + file.delete(); + } + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_INDEX_MEDIA_LATITUDE_LONGITUDE) + public void testQueryingMediaGeolocationDataInOrderByShouldReturnNonEmptyCursor() + throws Exception { + String testFileName = "test"; + final File downloads = new File(Environment.getExternalStorageDirectory(), + Environment.DIRECTORY_DOWNLOADS); + File file = stage(R.raw.lg_g4_iso_800_jpg, new File(downloads, testFileName)); + ModernMediaScanner modernMediaScanner = new ModernMediaScanner(sIsolatedContext, + new TestConfigStore()); + Uri testFileUri = modernMediaScanner.scanFile(file, MediaScanner.REASON_UNKNOWN); + + String[] projection = new String[] { + ImageColumns._ID, + ImageColumns.DISPLAY_NAME + }; + try (Cursor cursor = sIsolatedContext.getContentResolver() + .query(testFileUri, projection, null, null, ImageColumns.LONGITUDE);) { + assertNotNull(cursor); + // Should return non-empty results + assertThat(cursor.getCount()).isEqualTo(1); + int nameIndex = cursor.getColumnIndex(ImageColumns.DISPLAY_NAME); + cursor.moveToFirst(); + // Assert name column accessed is non-null and valid + assertTrue(cursor.getString(nameIndex).contains(testFileName)); + } finally { + // Clean up + file.delete(); + } + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_INDEX_MEDIA_LATITUDE_LONGITUDE) + public void testQueryingMediaGeolocationDataInGroupByAndHavingShouldReturnEmptyCursor() + throws Exception { + String testFileName = "test"; + final File downloads = new File(Environment.getExternalStorageDirectory(), + Environment.DIRECTORY_DOWNLOADS); + File file = stage(R.raw.lg_g4_iso_800_jpg, new File(downloads, testFileName)); + ModernMediaScanner modernMediaScanner = new ModernMediaScanner(sIsolatedContext, + new TestConfigStore()); + Uri testFileUri = modernMediaScanner.scanFile(file, MediaScanner.REASON_UNKNOWN); + + String[] projection = new String[] { + ImageColumns._ID, + ImageColumns.DISPLAY_NAME + }; + Bundle queryArgs = new Bundle(); + queryArgs.putString(QUERY_ARG_SQL_GROUP_BY, ImageColumns.LATITUDE); + queryArgs.putString(QUERY_ARG_SQL_HAVING, ImageColumns.LONGITUDE + " > 100"); + try (Cursor cursor = sIsolatedContext.getContentResolver() + .query(testFileUri, projection, queryArgs, null);) { + assertNotNull(cursor); + // Should not return any results + assertThat(cursor.getCount()).isEqualTo(0); + } finally { + // Clean up + file.delete(); + } + } + + private void testRedactionForFileExtension(int resId, String extension) throws Exception { final File dir = Environment .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); @@ -1985,4 +2422,24 @@ public class MediaProviderTest { sIsolatedResolver = sIsolatedContext.getContentResolver(); sItemsProvider = new ItemsProvider(sIsolatedContext); } + + @Test + public void testGetType() throws Exception { + final File dir = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + final File file = new File(dir, "test" + System.nanoTime() + ".jpg"); + stage(R.raw.lg_g4_iso_800_jpg, file); + final Uri uri = MediaStore.scanFile(sIsolatedResolver, file); + try (ContentProviderClient cpc = sIsolatedResolver + .acquireContentProviderClient(MediaStore.AUTHORITY)) { + final MediaProvider mp = (MediaProvider) cpc.getLocalContentProvider(); + final Uri redactedUri = mp.getRedactedUri(uri); + + final String actualType = mp.getType(redactedUri); + + assertEquals("image/dng", actualType); + } finally { + file.delete(); + } + } } diff --git a/tests/src/com/android/providers/media/PermissionActivityTest.java b/tests/src/com/android/providers/media/PermissionActivityTest.java index 1e8b1cd10..984c9c2ca 100644 --- a/tests/src/com/android/providers/media/PermissionActivityTest.java +++ b/tests/src/com/android/providers/media/PermissionActivityTest.java @@ -529,13 +529,16 @@ public class PermissionActivityTest { /* attributionTag= */ null); } else if (TextUtils.equals(op, OP_READ_MEDIA_IMAGES)) { return expected == checkPermissionReadImages( - context, pid, uid, packageName, /* attributionTag= */ null, /* isAtleastT */ true); + context, pid, uid, packageName, /* attributionTag= */ null, /* isAtleastT */ true, + /* forDataDelivery */ true); } else if (TextUtils.equals(op, OP_READ_MEDIA_AUDIO)) { return expected == checkPermissionReadAudio( - context, pid, uid, packageName, /* attributionTag= */ null, /* isAtleastT */ true); + context, pid, uid, packageName, /* attributionTag= */ null, /* isAtleastT */ true, + /* forDataDelivery */ true); } else if (TextUtils.equals(op, OP_READ_MEDIA_VIDEO)) { return expected == checkPermissionReadVideo( - context, pid, uid, packageName, /* attributionTag= */ null, /* isAtleastT */ true); + context, pid, uid, packageName, /* attributionTag= */ null, /* isAtleastT */ true, + /* forDataDelivery */ true); } else if (TextUtils.equals(op, OP_MANAGE_EXTERNAL_STORAGE)) { return expected == checkPermissionManager(context, pid, uid, packageName, /* attributionTag= */ null); diff --git a/tests/src/com/android/providers/media/PhotoPickerTranscodeHelperTest.java b/tests/src/com/android/providers/media/PhotoPickerTranscodeHelperTest.java new file mode 100644 index 000000000..7b01f57f2 --- /dev/null +++ b/tests/src/com/android/providers/media/PhotoPickerTranscodeHelperTest.java @@ -0,0 +1,287 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media; + +import static android.os.Environment.DIRECTORY_DOWNLOADS; +import static android.os.Environment.getExternalStorageDirectory; + +import static androidx.test.InstrumentationRegistry.getTargetContext; + +import static com.android.providers.media.PhotoPickerTranscodeHelper.Media3Transcoder; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.net.Uri; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.os.UserHandle; + +import androidx.annotation.NonNull; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.Arrays; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class PhotoPickerTranscodeHelperTest { + + private final int mTestUserId = UserHandle.myUserId(); + private final String mTestHost = "test"; + private final String mTestMediaId = "123"; + private final Uri mTestUri = Uri.parse("content://media/picker/" + mTestUserId + "/" + + mTestHost + "/media/" + mTestMediaId); + private PhotoPickerTranscodeHelper mHelper; + private File mTestDirectory; + + @Before + public void setUp() { + mTestDirectory = new File(getExternalStorageDirectory(), + DIRECTORY_DOWNLOADS + "/.picker_transcoded"); + mHelper = new PhotoPickerTranscodeHelper(mTestDirectory); + } + + @After + public void tearDown() { + mTestDirectory.delete(); + } + + @Test + public void transcode() throws IOException { + // Act & Assert. + PhotoPickerTranscodeHelper helper = getTranscodeHelper(new Media3Transcoder() { + @Override + int transcode(@NonNull Context context, @NonNull Uri sourceUri, + @NonNull String destinationPath, int timeoutSec, boolean useOpenGl) { + return Media3Transcoder.TRANSCODING_SUCCESS; + } + }); + assertThat(helper.transcode(getTargetContext(), mTestUri)).isTrue(); + } + + @Test + public void transcode_doNotUseTranscoder_whenCacheFileExists() throws IOException { + // Pre-create corresponded cached transcoded file. + final File cachedFile = new File(mTestDirectory, mTestHost + "_" + mTestMediaId); + if (cachedFile.createNewFile()) { + assertThat(cachedFile.exists()).isTrue(); + } + + // Act & Assert. + PhotoPickerTranscodeHelper helper = getTranscodeHelper(new Media3Transcoder() { + @Override + int transcode(@NonNull Context context, @NonNull Uri sourceUri, + @NonNull String destinationPath, int timeoutSec, boolean useOpenGl) { + return PhotoPickerTranscodeHelper.Media3Transcoder.TRANSCODING_OTHER_EXCEPTION; + } + }); + assertThat(helper.transcode(getTargetContext(), mTestUri)).isTrue(); + + // Cleanup. + cachedFile.delete(); + } + + @Test + public void transcode_canRetry_whenOpenGlExportFailed() throws IOException { + // Act & Assert. + PhotoPickerTranscodeHelper helper = getTranscodeHelper(new Media3Transcoder() { + + private boolean mHasTriedWithOpenGl = false; + + @Override + int transcode(@NonNull Context context, @NonNull Uri sourceUri, + @NonNull String destinationPath, int timeoutSec, boolean useOpenGl) { + if (useOpenGl) { + mHasTriedWithOpenGl = true; + return Media3Transcoder.TRANSCODING_EXPORT_EXCEPTION; + } else if (mHasTriedWithOpenGl) { + return Media3Transcoder.TRANSCODING_SUCCESS; + } else { + return Media3Transcoder.TRANSCODING_OTHER_EXCEPTION; + } + } + }); + assertThat(helper.transcode(getTargetContext(), mTestUri)).isTrue(); + } + + @Test + public void transcode_returnFalse_whenTranscoderFailed() throws IOException { + // Act & Assert. + PhotoPickerTranscodeHelper helper = getTranscodeHelper(new Media3Transcoder() { + @Override + int transcode(@NonNull Context context, @NonNull Uri sourceUri, + @NonNull String destinationPath, int timeoutSec, boolean useOpenGl) { + return Media3Transcoder.TRANSCODING_EXPORT_EXCEPTION; + } + }); + assertThat(helper.transcode(getTargetContext(), mTestUri)).isFalse(); + } + + @Test + public void transcode_returnFalse_whenTimeout() throws IOException { + // Act & Assert. + PhotoPickerTranscodeHelper helper = getTranscodeHelper(new Media3Transcoder() { + @Override + int transcode(@NonNull Context context, @NonNull Uri sourceUri, + @NonNull String destinationPath, int timeoutSec, boolean useOpenGl) { + return Media3Transcoder.TRANSCODING_TIMEOUT_EXCEPTION; + } + }); + assertThat(helper.transcode(getTargetContext(), mTestUri)).isFalse(); + } + + @Test + public void openTranscodedFile() throws IOException { + // Pre-create corresponded cached transcoded file. + final File cachedFile = new File(mTestDirectory, mTestHost + "_" + mTestMediaId); + if (cachedFile.createNewFile()) { + assertThat(cachedFile.exists()).isTrue(); + } + + // Act & Assert. + ParcelFileDescriptor pfd = mHelper.openTranscodedFile(mTestHost, mTestMediaId); + assertThat(pfd).isNotNull(); + + // Cleanup. + cachedFile.delete(); + } + + @Test + public void getTranscodedFileSize() throws IOException { + // Pre-create corresponded cached transcoded file. + final File cachedFile = new File(mTestDirectory, mTestHost + "_" + mTestMediaId); + if (cachedFile.createNewFile()) { + assertThat(cachedFile.exists()).isTrue(); + } + final RandomAccessFile raf = new RandomAccessFile(cachedFile, "rw"); + raf.setLength(3); + + // Act & Assert. + long fileSize = mHelper.getTranscodedFileSize(mTestHost, mTestMediaId); + assertThat(fileSize).isEqualTo(3); + + // Cleanup. + cachedFile.delete(); + } + + @Test + public void freeCache() throws IOException { + final List<String> fileNamesToBeFreed = Arrays.asList("100", "101", "102"); + createTestFiles(fileNamesToBeFreed); + + // Act. + mHelper.freeCache(1024); + + // Assert. + for (String fileName : fileNamesToBeFreed) { + final File file = new File(mTestDirectory, fileName); + assertThat(file.exists()).isFalse(); + } + } + + @Test + public void freeCache_returnCorrectBytesFreed() throws IOException { + // Pre-create corresponded cached transcoded file. + final File cachedFile = new File(mTestDirectory, mTestHost + "_" + mTestMediaId); + if (cachedFile.createNewFile()) { + assertThat(cachedFile.exists()).isTrue(); + } + final RandomAccessFile raf = new RandomAccessFile(cachedFile, "rw"); + raf.setLength(17); + + // Act. + final long bytesFreed = mHelper.freeCache(1024); + + // Assert. + assertThat(cachedFile.exists()).isFalse(); + assertThat(bytesFreed).isEqualTo(17); + } + + @Test + public void cleanAllTranscodedFiles() throws IOException { + final List<String> fileNamesToBeFreed = Arrays.asList("100", "101", "102"); + createTestFiles(fileNamesToBeFreed); + + // Act. + mHelper.cleanAllTranscodedFiles(null); + + // Assert. + for (String fileName : fileNamesToBeFreed) { + final File file = new File(mTestDirectory, fileName); + assertThat(file.exists()).isFalse(); + } + } + + @Test + public void cleanAllTranscodedFiles_notPerform_whenCancellationsSignalSet() throws IOException { + final List<String> fileNamesToBeFreed = Arrays.asList("100", "101", "102"); + createTestFiles(fileNamesToBeFreed); + + // Act. + CancellationSignal signal = new CancellationSignal(); + signal.cancel(); + mHelper.cleanAllTranscodedFiles(signal); + + // Assert. + for (String fileName : fileNamesToBeFreed) { + final File file = new File(mTestDirectory, fileName); + assertThat(file.exists()).isTrue(); + + // Cleanup. + file.delete(); + } + } + + @Test + public void deleteCachedTranscodedFile() throws IOException { + final long localId = 123; + final File fileToBeDeleted = new File(mTestDirectory, mTestHost + "_" + "123"); + if (fileToBeDeleted.createNewFile()) { + assertThat(fileToBeDeleted.exists()).isTrue(); + } + + // Act. + mHelper.deleteCachedTranscodedFile(mTestHost, localId); + + // Assert. + assertThat(fileToBeDeleted.exists()).isFalse(); + } + + @NonNull + private PhotoPickerTranscodeHelper getTranscodeHelper(@NonNull Media3Transcoder transcoder) { + PhotoPickerTranscodeHelper helper = new PhotoPickerTranscodeHelper(mTestDirectory); + helper.setTranscoder(transcoder); + return helper; + } + + private void createTestFiles(@NonNull List<String> fileNames) throws IOException { + for (String fileName : fileNames) { + File file = new File(mTestDirectory, fileName); + if (file.createNewFile()) { + assertThat(file.exists()).isTrue(); + } + } + } +} diff --git a/tests/src/com/android/providers/media/PickerUriResolverTest.java b/tests/src/com/android/providers/media/PickerUriResolverTest.java index 3f6641b53..c0504d0de 100644 --- a/tests/src/com/android/providers/media/PickerUriResolverTest.java +++ b/tests/src/com/android/providers/media/PickerUriResolverTest.java @@ -80,6 +80,7 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.util.ArrayList; import java.util.List; +import java.util.Locale; @RunWith(AndroidJUnit4.class) public class PickerUriResolverTest { @@ -283,6 +284,24 @@ public class PickerUriResolverTest { } @Test + public void testOpenPickerTranscodedFile() throws Exception { + final Uri transcodedUri = Uri.parse( + String.format( + Locale.ROOT, + "content://media/picker_transcoded/%d/com.android.providers.media" + + ".photopicker/media/", + UserHandle.myUserId()) + TEST_ID); + updateReadUriPermission(transcodedUri, /* grant */ true); + + // Act & Assert. + try (ParcelFileDescriptor pfd = sTestPickerUriResolver.openFile(transcodedUri, + "r", /* signal */ null, + LocalCallingIdentity.forTest(sCurrentContext, /* uid */ -1, /* permission */0))) { + assertThat(pfd).isNotNull(); + } + } + + @Test public void testProcessUrisForSelection_withoutPermissionOrAuthorityChecks() { sTestPickerUri = getPickerUriForId(ContentUris.parseId(sMediaStoreUriInOtherContext), TEST_USER, ACTION_PICK_IMAGES); diff --git a/tests/src/com/android/providers/media/SearchTestingUtils.java b/tests/src/com/android/providers/media/SearchTestingUtils.java new file mode 100644 index 000000000..bd8ada470 --- /dev/null +++ b/tests/src/com/android/providers/media/SearchTestingUtils.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; + +import com.android.providers.media.search.SearchUtilConstants; + +/** + * Holds utility functions for testing picker search workers + */ +public class SearchTestingUtils { + + private final DatabaseHelper mDatabaseHelper; + private final SearchUtilConstants mSearchUtilConstants; + + public SearchTestingUtils(DatabaseHelper databaseHelper) { + mDatabaseHelper = databaseHelper; + mSearchUtilConstants = new SearchUtilConstants(); + } + + /** + * Returns the processing statuses from the status table for the test file id + */ + public Cursor getMediaItemDataFromStatusTable(Long testFileId) { + return mDatabaseHelper.runWithoutTransaction(database -> { + return database.query( + /* tableName */ mSearchUtilConstants.MEDIA_STATUS_TABLE, + /* columns */ new String[] { + mSearchUtilConstants.METADATA_PROCESSING_STATUS_COLUMN, + mSearchUtilConstants.LABEL_PROCESSING_STATUS_COLUMN, + mSearchUtilConstants.LOCATION_PROCESSING_STATUS_COLUMN, + mSearchUtilConstants.OCR_PROCESSING_STATUS_COLUMN, + mSearchUtilConstants.DISPLAY_NAME_COLUMN + }, + /* selection */ mSearchUtilConstants.MEDIA_ID_COLUMN + " = ?", + /* selectionArgs */ new String[] { String.valueOf(testFileId) }, + /* groupBy */ null, /* having */ null, /*orderBy*/ null, /* limit */null); + }); + + } + + /** + * Marks the testFileId as trashed in the files table + */ + public void trashMediaItem(String testFileId) { + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.Files.FileColumns.IS_TRASHED, 1); + String updateColumn = MediaStore.Files.FileColumns._ID + " = ?"; + String[] updateArguments = new String[] { testFileId }; + mDatabaseHelper.runWithTransaction(database -> { + return database.update( + MediaStore.Files.TABLE, contentValues, updateColumn, updateArguments); + }); + } + + /** + * Inserts the given values into the status table + */ + public void insertMediaItemIntoMediaStatusTable( + DatabaseHelper databaseHelper, ContentValues insertValues) { + databaseHelper.runWithTransaction(database -> { + return database.insert( + mSearchUtilConstants.MEDIA_STATUS_TABLE, + /* nullColumnHack */ null, + insertValues); + }); + } + + /** + * Updates the given media item data in the specified table with the given values + */ + public void updateMediaItem(String tableName, String mediaId, ContentValues updateValues) { + String updateColumn = getUpdateColumn(tableName); + String[] updateArguments = new String[] { mediaId }; + mDatabaseHelper.runWithTransaction(database -> { + return database.update(tableName, updateValues, updateColumn, updateArguments); + }); + } + + private String getUpdateColumn(String tableName) { + if (tableName.equals(mSearchUtilConstants.MEDIA_STATUS_TABLE)) { + return mSearchUtilConstants.MEDIA_ID_COLUMN + " = ?"; + } else if (tableName.equals(MediaStore.Files.TABLE)) { + return MediaStore.Files.FileColumns._ID + " = ?"; + } + throw new IllegalArgumentException("Invalid table name"); + } + + /** + * Deletes the created test file after the tests have executed + */ + public void deleteTestFile(ContentResolver isolatedResolver, long testFileId) { + Uri testFileUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL, testFileId); + isolatedResolver.delete(testFileUri, Bundle.EMPTY); + } +} diff --git a/tests/src/com/android/providers/media/TestDatabaseBackupAndRecovery.java b/tests/src/com/android/providers/media/TestDatabaseBackupAndRecovery.java index 5a2f2017c..259d5caea 100644 --- a/tests/src/com/android/providers/media/TestDatabaseBackupAndRecovery.java +++ b/tests/src/com/android/providers/media/TestDatabaseBackupAndRecovery.java @@ -16,7 +16,6 @@ package com.android.providers.media; -import android.content.Context; import android.provider.MediaStore; import com.android.providers.media.fuse.FuseDaemon; @@ -98,10 +97,10 @@ public class TestDatabaseBackupAndRecovery extends DatabaseBackupAndRecovery { } @Override - protected void waitForVolumeToBeAttached(Set<String> setupCompleteVolumes) { + protected void waitForVolumeToBeAttached(String volumeName) { } @Override - protected void queuePublicVolumeRecovery(Context context) { + protected void markPublicVolumesRecovery() { } } diff --git a/tests/src/com/android/providers/media/backupandrestore/BackupAndRestoreTestUtils.java b/tests/src/com/android/providers/media/backupandrestore/BackupAndRestoreTestUtils.java new file mode 100644 index 000000000..228be1b40 --- /dev/null +++ b/tests/src/com/android/providers/media/backupandrestore/BackupAndRestoreTestUtils.java @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2025 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. + */ + +package com.android.providers.media.backupandrestore; + +import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils.FIELD_SEPARATOR; +import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils.KEY_VALUE_SEPARATOR; +import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils.RESTORE_COMPLETED; +import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils.SHARED_PREFERENCE_NAME; + +import android.content.Context; +import android.provider.MediaStore; + +import java.util.HashMap; +import java.util.Map; + +public class BackupAndRestoreTestUtils { + + /** + * Map used to store column name for given key id. + */ + private static Map<String, String> sColumnIdToKeyMap; + + /** + * Map used to store key id for given column name. + */ + private static Map<String, String> sColumnNameToIdMap; + + static void createKeyToColumnNameMap() { + sColumnIdToKeyMap = new HashMap<>(); + sColumnIdToKeyMap.put("0", MediaStore.Files.FileColumns.IS_FAVORITE); + sColumnIdToKeyMap.put("1", MediaStore.Files.FileColumns.MEDIA_TYPE); + sColumnIdToKeyMap.put("2", MediaStore.Files.FileColumns.MIME_TYPE); + sColumnIdToKeyMap.put("3", MediaStore.Files.FileColumns._USER_ID); + sColumnIdToKeyMap.put("4", MediaStore.Files.FileColumns.SIZE); + sColumnIdToKeyMap.put("5", MediaStore.MediaColumns.DATE_TAKEN); + sColumnIdToKeyMap.put("6", MediaStore.MediaColumns.CD_TRACK_NUMBER); + sColumnIdToKeyMap.put("7", MediaStore.MediaColumns.ALBUM); + sColumnIdToKeyMap.put("8", MediaStore.MediaColumns.ARTIST); + sColumnIdToKeyMap.put("9", MediaStore.MediaColumns.AUTHOR); + sColumnIdToKeyMap.put("10", MediaStore.MediaColumns.COMPOSER); + sColumnIdToKeyMap.put("11", MediaStore.MediaColumns.GENRE); + sColumnIdToKeyMap.put("12", MediaStore.MediaColumns.TITLE); + sColumnIdToKeyMap.put("13", MediaStore.MediaColumns.YEAR); + sColumnIdToKeyMap.put("14", MediaStore.MediaColumns.DURATION); + sColumnIdToKeyMap.put("15", MediaStore.MediaColumns.NUM_TRACKS); + sColumnIdToKeyMap.put("16", MediaStore.MediaColumns.WRITER); + sColumnIdToKeyMap.put("17", MediaStore.MediaColumns.ALBUM_ARTIST); + sColumnIdToKeyMap.put("18", MediaStore.MediaColumns.DISC_NUMBER); + sColumnIdToKeyMap.put("19", MediaStore.MediaColumns.COMPILATION); + sColumnIdToKeyMap.put("20", MediaStore.MediaColumns.BITRATE); + sColumnIdToKeyMap.put("21", MediaStore.MediaColumns.CAPTURE_FRAMERATE); + sColumnIdToKeyMap.put("22", MediaStore.Audio.AudioColumns.TRACK); + sColumnIdToKeyMap.put("23", MediaStore.MediaColumns.DOCUMENT_ID); + sColumnIdToKeyMap.put("24", MediaStore.MediaColumns.INSTANCE_ID); + sColumnIdToKeyMap.put("25", MediaStore.MediaColumns.ORIGINAL_DOCUMENT_ID); + sColumnIdToKeyMap.put("26", MediaStore.MediaColumns.RESOLUTION); + sColumnIdToKeyMap.put("27", MediaStore.MediaColumns.ORIENTATION); + sColumnIdToKeyMap.put("28", MediaStore.Video.VideoColumns.COLOR_STANDARD); + sColumnIdToKeyMap.put("29", MediaStore.Video.VideoColumns.COLOR_TRANSFER); + sColumnIdToKeyMap.put("30", MediaStore.Video.VideoColumns.COLOR_RANGE); + sColumnIdToKeyMap.put("31", MediaStore.Files.FileColumns._VIDEO_CODEC_TYPE); + sColumnIdToKeyMap.put("32", MediaStore.MediaColumns.WIDTH); + sColumnIdToKeyMap.put("33", MediaStore.MediaColumns.HEIGHT); + sColumnIdToKeyMap.put("34", MediaStore.Images.ImageColumns.DESCRIPTION); + sColumnIdToKeyMap.put("35", MediaStore.Images.ImageColumns.EXPOSURE_TIME); + sColumnIdToKeyMap.put("36", MediaStore.Images.ImageColumns.F_NUMBER); + sColumnIdToKeyMap.put("37", MediaStore.Images.ImageColumns.ISO); + sColumnIdToKeyMap.put("38", MediaStore.Images.ImageColumns.SCENE_CAPTURE_TYPE); + sColumnIdToKeyMap.put("39", MediaStore.Files.FileColumns._SPECIAL_FORMAT); + sColumnIdToKeyMap.put("40", MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME); + // Adding number gap to allow addition of new values + sColumnIdToKeyMap.put("80", MediaStore.MediaColumns.XMP); + } + + static void createColumnNameToKeyMap() { + sColumnNameToIdMap = new HashMap<>(); + sColumnNameToIdMap.put(MediaStore.Files.FileColumns.IS_FAVORITE, "0"); + sColumnNameToIdMap.put(MediaStore.Files.FileColumns.MEDIA_TYPE, "1"); + sColumnNameToIdMap.put(MediaStore.Files.FileColumns.MIME_TYPE, "2"); + sColumnNameToIdMap.put(MediaStore.Files.FileColumns._USER_ID, "3"); + sColumnNameToIdMap.put(MediaStore.Files.FileColumns.SIZE, "4"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.DATE_TAKEN, "5"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.CD_TRACK_NUMBER, "6"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.ALBUM, "7"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.ARTIST, "8"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.AUTHOR, "9"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.COMPOSER, "10"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.GENRE, "11"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.TITLE, "12"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.YEAR, "13"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.DURATION, "14"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.NUM_TRACKS, "15"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.WRITER, "16"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.ALBUM_ARTIST, "17"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.DISC_NUMBER, "18"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.COMPILATION, "19"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.BITRATE, "20"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.CAPTURE_FRAMERATE, "21"); + sColumnNameToIdMap.put(MediaStore.Audio.AudioColumns.TRACK, "22"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.DOCUMENT_ID, "23"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.INSTANCE_ID, "24"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.ORIGINAL_DOCUMENT_ID, "25"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.RESOLUTION, "26"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.ORIENTATION, "27"); + sColumnNameToIdMap.put(MediaStore.Video.VideoColumns.COLOR_STANDARD, "28"); + sColumnNameToIdMap.put(MediaStore.Video.VideoColumns.COLOR_TRANSFER, "29"); + sColumnNameToIdMap.put(MediaStore.Video.VideoColumns.COLOR_RANGE, "30"); + sColumnNameToIdMap.put(MediaStore.Files.FileColumns._VIDEO_CODEC_TYPE, "31"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.WIDTH, "32"); + sColumnNameToIdMap.put(MediaStore.MediaColumns.HEIGHT, "33"); + sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.DESCRIPTION, "34"); + sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.EXPOSURE_TIME, "35"); + sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.F_NUMBER, "36"); + sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.ISO, "37"); + sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.SCENE_CAPTURE_TYPE, "38"); + sColumnNameToIdMap.put(MediaStore.Files.FileColumns._SPECIAL_FORMAT, "39"); + sColumnNameToIdMap.put(MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME, "40"); + // Adding number gap to allow addition of new values + sColumnNameToIdMap.put(MediaStore.MediaColumns.XMP, "80"); + } + + static Map<String, String> deSerialiseValueString(String valueString) { + if (sColumnIdToKeyMap == null) { + createKeyToColumnNameMap(); + } + + String[] values = valueString.split(":::"); + Map<String, String> map = new HashMap<>(); + for (String value : values) { + if (value == null || value.isEmpty()) { + continue; + } + + String[] keyValue = value.split("=", 2); + map.put(sColumnIdToKeyMap.get(keyValue[0]), keyValue[1]); + } + + return map; + } + + static String createSerialisedValue(Map<String, String> entries) { + if (sColumnNameToIdMap == null) { + createColumnNameToKeyMap(); + } + + StringBuilder sb = new StringBuilder(); + for (String backupColumn : sColumnNameToIdMap.keySet()) { + if (entries.containsKey(backupColumn)) { + sb.append(sColumnNameToIdMap.get(backupColumn)).append(KEY_VALUE_SEPARATOR).append( + entries.get(backupColumn)); + sb.append(FIELD_SEPARATOR); + } + } + return sb.toString(); + } + + static boolean getSharedPreferenceValue(Context context) { + return context.getSharedPreferences(SHARED_PREFERENCE_NAME, + Context.MODE_PRIVATE).getBoolean(RESTORE_COMPLETED, false); + } +} diff --git a/tests/src/com/android/providers/media/backupandrestore/BackupExecutorTest.java b/tests/src/com/android/providers/media/backupandrestore/BackupExecutorTest.java index d464dffc3..70c7eb373 100644 --- a/tests/src/com/android/providers/media/backupandrestore/BackupExecutorTest.java +++ b/tests/src/com/android/providers/media/backupandrestore/BackupExecutorTest.java @@ -16,6 +16,7 @@ package com.android.providers.media.backupandrestore; +import static com.android.providers.media.backupandrestore.BackupAndRestoreTestUtils.deSerialiseValueString; import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils.BACKUP_COLUMNS; import static com.android.providers.media.scan.MediaScanner.REASON_UNKNOWN; import static com.android.providers.media.scan.MediaScannerTest.stage; @@ -51,7 +52,6 @@ import com.android.providers.media.util.FileUtils; import org.junit.After; import org.junit.Before; -import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -75,11 +75,6 @@ public final class BackupExecutorTest { @Rule public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); - /** - * Map used to store key id for given column and vice versa. - */ - private static Map<String, String> sColumnIdToKeyMap; - private Set<File> mStagedFiles = new HashSet<>(); private Context mIsolatedContext; @@ -90,11 +85,6 @@ public final class BackupExecutorTest { private File mDownloadsDir; - @BeforeClass - public static void setupBeforeClass() { - createColumnToKeyMap(); - } - private String mLevelDbPath; @Before @@ -216,71 +206,9 @@ public final class BackupExecutorTest { } } - static Map<String, String> deSerialiseValueString(String valueString) { - String[] values = valueString.split(":::"); - Map<String, String> map = new HashMap<>(); - for (String value : values) { - if (value == null || value.isEmpty()) { - continue; - } - - String[] keyValue = value.split("=", 2); - map.put(sColumnIdToKeyMap.get(keyValue[0]), keyValue[1]); - } - - return map; - } - private void stageNewFile(int resId, File file) throws IOException { file.createNewFile(); mStagedFiles.add(file); stage(resId, file); } - - static void createColumnToKeyMap() { - sColumnIdToKeyMap = new HashMap<>(); - sColumnIdToKeyMap.put("0", MediaStore.Files.FileColumns.IS_FAVORITE); - sColumnIdToKeyMap.put("1", MediaStore.Files.FileColumns.MEDIA_TYPE); - sColumnIdToKeyMap.put("2", MediaStore.Files.FileColumns.MIME_TYPE); - sColumnIdToKeyMap.put("3", MediaStore.Files.FileColumns._USER_ID); - sColumnIdToKeyMap.put("4", MediaStore.Files.FileColumns.SIZE); - sColumnIdToKeyMap.put("5", MediaStore.MediaColumns.DATE_TAKEN); - sColumnIdToKeyMap.put("6", MediaStore.MediaColumns.CD_TRACK_NUMBER); - sColumnIdToKeyMap.put("7", MediaStore.MediaColumns.ALBUM); - sColumnIdToKeyMap.put("8", MediaStore.MediaColumns.ARTIST); - sColumnIdToKeyMap.put("9", MediaStore.MediaColumns.AUTHOR); - sColumnIdToKeyMap.put("10", MediaStore.MediaColumns.COMPOSER); - sColumnIdToKeyMap.put("11", MediaStore.MediaColumns.GENRE); - sColumnIdToKeyMap.put("12", MediaStore.MediaColumns.TITLE); - sColumnIdToKeyMap.put("13", MediaStore.MediaColumns.YEAR); - sColumnIdToKeyMap.put("14", MediaStore.MediaColumns.DURATION); - sColumnIdToKeyMap.put("15", MediaStore.MediaColumns.NUM_TRACKS); - sColumnIdToKeyMap.put("16", MediaStore.MediaColumns.WRITER); - sColumnIdToKeyMap.put("17", MediaStore.MediaColumns.ALBUM_ARTIST); - sColumnIdToKeyMap.put("18", MediaStore.MediaColumns.DISC_NUMBER); - sColumnIdToKeyMap.put("19", MediaStore.MediaColumns.COMPILATION); - sColumnIdToKeyMap.put("20", MediaStore.MediaColumns.BITRATE); - sColumnIdToKeyMap.put("21", MediaStore.MediaColumns.CAPTURE_FRAMERATE); - sColumnIdToKeyMap.put("22", MediaStore.Audio.AudioColumns.TRACK); - sColumnIdToKeyMap.put("23", MediaStore.MediaColumns.DOCUMENT_ID); - sColumnIdToKeyMap.put("24", MediaStore.MediaColumns.INSTANCE_ID); - sColumnIdToKeyMap.put("25", MediaStore.MediaColumns.ORIGINAL_DOCUMENT_ID); - sColumnIdToKeyMap.put("26", MediaStore.MediaColumns.RESOLUTION); - sColumnIdToKeyMap.put("27", MediaStore.MediaColumns.ORIENTATION); - sColumnIdToKeyMap.put("28", MediaStore.Video.VideoColumns.COLOR_STANDARD); - sColumnIdToKeyMap.put("29", MediaStore.Video.VideoColumns.COLOR_TRANSFER); - sColumnIdToKeyMap.put("30", MediaStore.Video.VideoColumns.COLOR_RANGE); - sColumnIdToKeyMap.put("31", MediaStore.Files.FileColumns._VIDEO_CODEC_TYPE); - sColumnIdToKeyMap.put("32", MediaStore.MediaColumns.WIDTH); - sColumnIdToKeyMap.put("33", MediaStore.MediaColumns.HEIGHT); - sColumnIdToKeyMap.put("34", MediaStore.Images.ImageColumns.DESCRIPTION); - sColumnIdToKeyMap.put("35", MediaStore.Images.ImageColumns.EXPOSURE_TIME); - sColumnIdToKeyMap.put("36", MediaStore.Images.ImageColumns.F_NUMBER); - sColumnIdToKeyMap.put("37", MediaStore.Images.ImageColumns.ISO); - sColumnIdToKeyMap.put("38", MediaStore.Images.ImageColumns.SCENE_CAPTURE_TYPE); - sColumnIdToKeyMap.put("39", MediaStore.Files.FileColumns._SPECIAL_FORMAT); - sColumnIdToKeyMap.put("40", MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME); - // Adding number gap to allow addition of new values - sColumnIdToKeyMap.put("80", MediaStore.MediaColumns.XMP); - } } diff --git a/tests/src/com/android/providers/media/backupandrestore/MediaBackupAgentTest.java b/tests/src/com/android/providers/media/backupandrestore/MediaBackupAgentTest.java new file mode 100644 index 000000000..5f892f126 --- /dev/null +++ b/tests/src/com/android/providers/media/backupandrestore/MediaBackupAgentTest.java @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2025 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. + */ + +package com.android.providers.media.backupandrestore; + +import static com.android.providers.media.backupandrestore.BackupAndRestoreTestUtils.deSerialiseValueString; +import static com.android.providers.media.backupandrestore.BackupAndRestoreTestUtils.getSharedPreferenceValue; +import static com.android.providers.media.scan.MediaScanner.REASON_UNKNOWN; +import static com.android.providers.media.scan.MediaScannerTest.stage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.Manifest; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.SystemClock; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; +import android.provider.MediaStore; + +import androidx.test.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SdkSuppress; + +import com.android.providers.media.IsolatedContext; +import com.android.providers.media.R; +import com.android.providers.media.TestConfigStore; +import com.android.providers.media.flags.Flags; +import com.android.providers.media.leveldb.LevelDBInstance; +import com.android.providers.media.leveldb.LevelDBManager; +import com.android.providers.media.leveldb.LevelDBResult; +import com.android.providers.media.scan.ModernMediaScanner; +import com.android.providers.media.util.FileUtils; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.util.Map; + +@RunWith(AndroidJUnit4.class) +@EnableFlags(Flags.FLAG_ENABLE_BACKUP_AND_RESTORE) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) +public class MediaBackupAgentTest { + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + private Context mIsolatedContext; + + private ContentResolver mIsolatedResolver; + + private ModernMediaScanner mModern; + + private File mDownloadsDir; + + private File mRestoreDir; + private File mBackupDir; + + private String mLevelDbPath; + private MediaBackupAgent mMediaBackupAgent; + + @Before + public void setUp() { + final Context context = InstrumentationRegistry.getTargetContext(); + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getUiAutomation() + .adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE, + Manifest.permission.READ_COMPAT_CHANGE_CONFIG, + Manifest.permission.DUMP, + Manifest.permission.READ_DEVICE_CONFIG); + + mIsolatedContext = new IsolatedContext(context, "modern", /*asFuseThread*/ false); + mIsolatedResolver = mIsolatedContext.getContentResolver(); + mModern = new ModernMediaScanner(mIsolatedContext, new TestConfigStore()); + mRestoreDir = new File(mIsolatedContext.getFilesDir(), "restore"); + mBackupDir = new File(mIsolatedContext.getFilesDir(), "backup"); + mDownloadsDir = new File(Environment.getExternalStorageDirectory(), + Environment.DIRECTORY_DOWNLOADS); + mLevelDbPath = + mIsolatedContext.getFilesDir().getAbsolutePath() + "/backup/external_primary/"; + FileUtils.deleteContents(mDownloadsDir); + + mMediaBackupAgent = new MediaBackupAgent(); + mMediaBackupAgent.attach(mIsolatedContext); + } + + @Test + public void testCompleteFlow() throws Exception { + //create new test file & stage it + File file = new File(mDownloadsDir, "testImage_" + + SystemClock.elapsedRealtimeNanos() + ".jpg"); + file.createNewFile(); + stage(R.raw.test_image, file); + + try { + String path = file.getAbsolutePath(); + // scan directory to have entry in files table + mModern.scanDirectory(mDownloadsDir, REASON_UNKNOWN); + + // set is_favorite value to 1. We will check this value later after restoration. + updateFavoritesValue(path, 1); + + // run idle maintenance, this will save file's metadata in leveldb with is_favorite = 1 + MediaStore.runIdleMaintenance(mIsolatedResolver); + assertTrue(mBackupDir.exists()); + + assertLevelDbExistsAndHasLatestValues(path); + + // run the backup agent. This will copy over backup directory to restore directory and + // set shared preference. + mMediaBackupAgent.onRestoreFinished(); + assertTrue(getSharedPreferenceValue(mIsolatedContext)); + assertTrue(mRestoreDir.exists()); + assertFalse(mBackupDir.exists()); + + //delete existing external db database having old values + mIsolatedContext.deleteDatabase("external.db"); + + // run media scan, this will populate db and read value from backup + mModern.scanDirectory(mDownloadsDir, REASON_UNKNOWN); + assertEquals(1, queryFavoritesValue(path)); + + // on idle maintenance, clean up is called. It should delete restore directory and set + // shared preference to false + MediaStore.runIdleMaintenance(mIsolatedResolver); + assertFalse(getSharedPreferenceValue(mIsolatedContext)); + assertFalse(mRestoreDir.exists()); + } finally { + file.delete(); + } + } + + private void assertLevelDbExistsAndHasLatestValues(String path) { + // check that entry is created in level db for the file + LevelDBInstance levelDBInstance = LevelDBManager.getInstance(mLevelDbPath); + assertNotNull(levelDBInstance); + + // check that entry created in level db has latest value(is_favorite = 1) + LevelDBResult levelDBResult = levelDBInstance.query(path); + assertNotNull(levelDBResult); + Map<String, String> actualResultMap = deSerialiseValueString(levelDBResult.getValue()); + assertEquals(1, + Integer.parseInt(actualResultMap.get(MediaStore.MediaColumns.IS_FAVORITE))); + } + + private void updateFavoritesValue(String path, int value) { + Uri uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL); + String selection = MediaStore.Files.FileColumns.DATA + " LIKE ?"; + String[] selectionArgs = new String[]{path}; + + ContentValues values = new ContentValues(); + values.put(MediaStore.Files.FileColumns.IS_FAVORITE, value); + values.put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis()); + + mIsolatedResolver.update(uri, values, selection, selectionArgs); + } + + private int queryFavoritesValue(String path) { + Uri uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL); + String selection = MediaStore.Files.FileColumns.DATA + " LIKE ?"; + String[] selectionArgs = new String[]{path}; + + Cursor cursor = mIsolatedResolver.query(uri, null, selection, selectionArgs, null); + cursor.moveToFirst(); + return cursor.getInt(cursor.getColumnIndex(MediaStore.Files.FileColumns.IS_FAVORITE)); + } +} diff --git a/tests/src/com/android/providers/media/backupandrestore/RestoreExecutorTest.java b/tests/src/com/android/providers/media/backupandrestore/RestoreExecutorTest.java index ebffecc13..9f0b41a21 100644 --- a/tests/src/com/android/providers/media/backupandrestore/RestoreExecutorTest.java +++ b/tests/src/com/android/providers/media/backupandrestore/RestoreExecutorTest.java @@ -16,8 +16,7 @@ package com.android.providers.media.backupandrestore; -import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils.FIELD_SEPARATOR; -import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils.KEY_VALUE_SEPARATOR; +import static com.android.providers.media.backupandrestore.BackupAndRestoreTestUtils.createSerialisedValue; import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils.RESTORE_COMPLETED; import static com.android.providers.media.scan.MediaScanner.REASON_UNKNOWN; import static com.android.providers.media.scan.MediaScannerTest.stage; @@ -53,7 +52,6 @@ import com.android.providers.media.util.FileUtils; import org.junit.After; import org.junit.Before; -import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -73,11 +71,6 @@ public final class RestoreExecutorTest { @Rule public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); - /** - * Map used to store key id for given column and vice versa. - */ - private static Map<String, String> sColumnNameToIdMap; - private Context mIsolatedContext; private ContentResolver mIsolatedResolver; @@ -86,11 +79,6 @@ public final class RestoreExecutorTest { private File mDownloadsDir; - @BeforeClass - public static void setupBeforeClass() { - createColumnToKeyMap(); - } - @Before public void setUp() { final Context context = InstrumentationRegistry.getTargetContext(); @@ -347,63 +335,4 @@ public final class RestoreExecutorTest { file.createNewFile(); stage(resId, file); } - - private String createSerialisedValue(Map<String, String> entries) { - StringBuilder sb = new StringBuilder(); - for (String backupColumn : sColumnNameToIdMap.keySet()) { - if (entries.containsKey(backupColumn)) { - sb.append(sColumnNameToIdMap.get(backupColumn)).append(KEY_VALUE_SEPARATOR).append( - entries.get(backupColumn)); - sb.append(FIELD_SEPARATOR); - } - } - return sb.toString(); - } - - private static void createColumnToKeyMap() { - sColumnNameToIdMap = new HashMap<>(); - sColumnNameToIdMap.put(MediaStore.Files.FileColumns.IS_FAVORITE, "0"); - sColumnNameToIdMap.put(MediaStore.Files.FileColumns.MEDIA_TYPE, "1"); - sColumnNameToIdMap.put(MediaStore.Files.FileColumns.MIME_TYPE, "2"); - sColumnNameToIdMap.put(MediaStore.Files.FileColumns._USER_ID, "3"); - sColumnNameToIdMap.put(MediaStore.Files.FileColumns.SIZE, "4"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.DATE_TAKEN, "5"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.CD_TRACK_NUMBER, "6"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.ALBUM, "7"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.ARTIST, "8"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.AUTHOR, "9"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.COMPOSER, "10"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.GENRE, "11"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.TITLE, "12"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.YEAR, "13"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.DURATION, "14"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.NUM_TRACKS, "15"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.WRITER, "16"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.ALBUM_ARTIST, "17"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.DISC_NUMBER, "18"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.COMPILATION, "19"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.BITRATE, "20"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.CAPTURE_FRAMERATE, "21"); - sColumnNameToIdMap.put(MediaStore.Audio.AudioColumns.TRACK, "22"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.DOCUMENT_ID, "23"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.INSTANCE_ID, "24"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.ORIGINAL_DOCUMENT_ID, "25"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.RESOLUTION, "26"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.ORIENTATION, "27"); - sColumnNameToIdMap.put(MediaStore.Video.VideoColumns.COLOR_STANDARD, "28"); - sColumnNameToIdMap.put(MediaStore.Video.VideoColumns.COLOR_TRANSFER, "29"); - sColumnNameToIdMap.put(MediaStore.Video.VideoColumns.COLOR_RANGE, "30"); - sColumnNameToIdMap.put(MediaStore.Files.FileColumns._VIDEO_CODEC_TYPE, "31"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.WIDTH, "32"); - sColumnNameToIdMap.put(MediaStore.MediaColumns.HEIGHT, "33"); - sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.DESCRIPTION, "34"); - sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.EXPOSURE_TIME, "35"); - sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.F_NUMBER, "36"); - sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.ISO, "37"); - sColumnNameToIdMap.put(MediaStore.Images.ImageColumns.SCENE_CAPTURE_TYPE, "38"); - sColumnNameToIdMap.put(MediaStore.Files.FileColumns._SPECIAL_FORMAT, "39"); - sColumnNameToIdMap.put(MediaStore.Files.FileColumns.OWNER_PACKAGE_NAME, "40"); - // Adding number gap to allow addition of new values - sColumnNameToIdMap.put(MediaStore.MediaColumns.XMP, "80"); - } } diff --git a/tests/src/com/android/providers/media/cloudproviders/SearchProvider.java b/tests/src/com/android/providers/media/cloudproviders/SearchProvider.java new file mode 100644 index 000000000..d0da49abe --- /dev/null +++ b/tests/src/com/android/providers/media/cloudproviders/SearchProvider.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.cloudproviders; + +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_2; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_3; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_4; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_2; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getAlbumCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getCloudMediaCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getLocalMediaCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getMediaCategoriesCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getSuggestionCursor; + +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.graphics.Point; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.CloudMediaProvider; +import android.provider.CloudMediaProviderContract; + +import java.io.FileNotFoundException; +import java.util.List; + +public class SearchProvider extends CloudMediaProvider { + public static final String AUTHORITY = + "com.android.providers.media.photopicker.tests.cloud_search_provider"; + + public static final MergeCursor DEFAULT_CLOUD_MEDIA = new MergeCursor(List.of( + getCloudMediaCursor(CLOUD_ID_1, LOCAL_ID_1, 0), + getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0), + getCloudMediaCursor(CLOUD_ID_3, null, 0), + getCloudMediaCursor(CLOUD_ID_4, null, 0) + ).toArray(new Cursor[0])); + + public static final MergeCursor DEFAULT_SUGGESTION_RESULTS = new MergeCursor(List.of( + getSuggestionCursor(CLOUD_ID_1), + getSuggestionCursor(CLOUD_ID_2) + ).toArray(new Cursor[0])); + + private static Cursor sSearchSuggestions = DEFAULT_SUGGESTION_RESULTS; + + public static final MergeCursor DEFAULT_CATEGORY_RESULTS = new MergeCursor(List.of( + getMediaCategoriesCursor("people_and_pets") + ).toArray(new Cursor[0])); + + private static Cursor sMediaCategories = DEFAULT_CATEGORY_RESULTS; + + public static final MergeCursor DEFAULT_ALBUM_RESULTS = new MergeCursor(List.of( + getAlbumCursor("cloud_album", 0L, /* coverId */ CLOUD_ID_1, AUTHORITY) + ).toArray(new Cursor[0])); + + private static Cursor sAlbums = DEFAULT_ALBUM_RESULTS; + + private static Cursor sSearchResults = getDefaultCloudSearchResults(); + + public static Cursor sMediaSets = getDefaultCursorForMediaSetSyncTest(); + + public static Cursor sMediaSetContents = getDefaultCloudSearchResults(); + + @Override + public Cursor onSearchMedia(String mediaSetId, String fallbackSearchText, + Bundle extras, CancellationSignal cancellationSignal) { + return sSearchResults; + } + + @Override + public Cursor onSearchMedia(String searchText, + Bundle extras, CancellationSignal cancellationSignal) { + return sSearchResults; + } + + @Override + public Cursor onQueryMediaInMediaSet(String mediaSetId, + Bundle extras, CancellationSignal cancellationSignal) { + return sMediaSetContents; + } + + @Override + public Cursor onQuerySearchSuggestions(String prefixText, Bundle extras, + CancellationSignal cancellationSignal) { + return sSearchSuggestions; + } + + @Override + public Cursor onQueryMediaSets(String mediaCategoryId, + Bundle extras, CancellationSignal cancellationSignal) { + return sMediaSets; + } + + @Override + public Cursor onQueryMediaCategories(String parentCategoryId, Bundle extras, + CancellationSignal cancellationSignal) { + return sMediaCategories; + } + + @Override + public Cursor onQueryAlbums(Bundle extras) { + return sAlbums; + } + + @Override + public CloudMediaProviderContract.Capabilities onGetCapabilities() { + return new CloudMediaProviderContract.Capabilities.Builder() + .setSearchEnabled(true) + .setMediaCategoriesEnabled(true) + .build(); + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor onQueryMedia(Bundle extras) { + throw new UnsupportedOperationException("onQueryMedia not supported"); + } + + @Override + public Cursor onQueryDeletedMedia(Bundle extras) { + throw new UnsupportedOperationException("onQueryDeletedMedia not supported"); + } + + @Override + public AssetFileDescriptor onOpenPreview( + String mediaId, + Point size, + Bundle extras, + CancellationSignal signal) throws FileNotFoundException { + throw new UnsupportedOperationException("onOpenPreview not supported"); + } + + @Override + public ParcelFileDescriptor onOpenMedia( + String mediaId, + Bundle extras, + CancellationSignal signal) throws FileNotFoundException { + throw new UnsupportedOperationException("onOpenMedia not supported"); + } + + @Override + public Bundle onGetMediaCollectionInfo(Bundle extras) { + throw new UnsupportedOperationException("onGetMediaCollectionInfo not supported"); + } + + public static void setSearchResults(Cursor searchResults) { + sSearchResults = searchResults; + } + + public static Cursor getSearchResults() { + return sSearchResults; + } + + public static void setMediaSets(Cursor mediaSets) { + sMediaSets = mediaSets; + } + + public static void setMediaSetContents(Cursor mediaSetContents) { + sMediaSetContents = mediaSetContents; + } + + /** + Returns a default media set data cursor for tests + */ + public static Cursor getDefaultCursorForMediaSetSyncTest() { + String[] columns = new String[]{ + CloudMediaProviderContract.MediaSetColumns.ID, + CloudMediaProviderContract.MediaSetColumns.DISPLAY_NAME, + CloudMediaProviderContract.MediaSetColumns.MEDIA_COVER_ID + }; + + MatrixCursor cursor = new MatrixCursor(columns); + cursor.addRow(new Object[] { "mediaSetId", "name", "id" }); + + return cursor; + } + + /** + * Returns a default set of cloud search results for tests. + */ + public static final MergeCursor getDefaultCloudSearchResults() { + return new MergeCursor(List.of( + getCloudMediaCursor(CLOUD_ID_1, LOCAL_ID_1, 1), + getCloudMediaCursor(CLOUD_ID_3, null, 0) + ).toArray(new Cursor[0])); + } + + /** + * Returns a default set of local search results for tests. + */ + public static final MergeCursor getDefaultLocalSearchResults() { + return new MergeCursor(List.of( + getLocalMediaCursor(LOCAL_ID_1, 1), + getLocalMediaCursor(LOCAL_ID_2, 0) + ).toArray(new Cursor[0])); + } +} diff --git a/tests/src/com/android/providers/media/oemmetadataservices/OemMetadataServiceTest.java b/tests/src/com/android/providers/media/oemmetadataservices/OemMetadataServiceTest.java index c01e06f00..774635be4 100644 --- a/tests/src/com/android/providers/media/oemmetadataservices/OemMetadataServiceTest.java +++ b/tests/src/com/android/providers/media/oemmetadataservices/OemMetadataServiceTest.java @@ -36,15 +36,13 @@ import android.os.Build; import android.os.Environment; import android.os.IBinder; import android.os.ParcelFileDescriptor; -import android.platform.test.annotations.RequiresFlagsEnabled; -import android.platform.test.flag.junit.CheckFlagsRule; -import android.platform.test.flag.junit.DeviceFlagsValueProvider; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; import android.provider.IOemMetadataService; import android.provider.MediaStore; import android.provider.MediaStore.Files.FileColumns; import android.provider.OemMetadataService; import android.provider.OemMetadataServiceWrapper; -import android.provider.media.internal.flags.Flags; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SdkSuppress; @@ -73,12 +71,11 @@ import java.util.concurrent.TimeoutException; import java.util.function.Supplier; @RunWith(AndroidJUnit4.class) -@RequiresFlagsEnabled(com.android.providers.media.flags.Flags.FLAG_ENABLE_OEM_METADATA) +@EnableFlags(com.android.providers.media.flags.Flags.FLAG_ENABLE_OEM_METADATA) @SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) public class OemMetadataServiceTest { - @Rule - public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); private static final long POLLING_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(5); private static final long POLLING_SLEEP_MILLIS = 100; @@ -179,6 +176,7 @@ public class OemMetadataServiceTest { } } finally { audioFile.delete(); + isolatedContext.unbindService(modernMediaScanner.getOemMetadataServiceConnection()); } } diff --git a/tests/src/com/android/providers/media/photopicker/CategoriesStateTest.java b/tests/src/com/android/providers/media/photopicker/CategoriesStateTest.java new file mode 100644 index 000000000..6b6e06387 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/CategoriesStateTest.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.os.Build; +import android.platform.test.annotations.DisableFlags; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; + +import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.providers.media.TestConfigStore; +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.flags.Flags; + +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; + +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU) +public class CategoriesStateTest { + + private Context mContext; + + @ClassRule + public static final SetFlagsRule.ClassRule mSetFlagsClassRule = new SetFlagsRule.ClassRule(); + @Rule + public final SetFlagsRule mSetFlagsRule = mSetFlagsClassRule.createSetFlagsRule(); + + @Before + public void setup() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @Test + @EnableFlags({Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH, + Flags.FLAG_ENABLE_CLOUD_MEDIA_PROVIDER_CAPABILITIES}) + public void testAreMediaCategoriesEnabledWithValidAuthority() { + final TestConfigStore configStore = new TestConfigStore(); + configStore.setIsModernPickerEnabled(true); + + final CategoriesState categoriesState = new CategoriesState(configStore); + assertTrue(categoriesState.areCategoriesEnabled(mContext, SearchProvider.AUTHORITY)); + } + + @Test + @EnableFlags({Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH, + Flags.FLAG_ENABLE_CLOUD_MEDIA_PROVIDER_CAPABILITIES}) + public void testAreMediaCategoriesEnabledWithVInvalidAuthority() { + final TestConfigStore configStore = new TestConfigStore(); + configStore.setIsModernPickerEnabled(true); + + final CategoriesState categoriesState = new CategoriesState(configStore); + assertFalse(categoriesState.areCategoriesEnabled(mContext, "invalid")); + } + + @Test + @EnableFlags({Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH, + Flags.FLAG_ENABLE_CLOUD_MEDIA_PROVIDER_CAPABILITIES}) + public void testAreMediaCategoriesEnabledWithModernPickerDisabled() { + final TestConfigStore configStore = new TestConfigStore(); + configStore.setIsModernPickerEnabled(false); + + final CategoriesState categoriesState = new CategoriesState(configStore); + assertFalse(categoriesState.areCategoriesEnabled(mContext, SearchProvider.AUTHORITY)); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_CLOUD_MEDIA_PROVIDER_CAPABILITIES) + @DisableFlags(Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH) + public void testAreMediaCategoriesEnabledWithPhotopickerSearchFlagDisabled() { + final TestConfigStore configStore = new TestConfigStore(); + configStore.setIsModernPickerEnabled(true); + + final CategoriesState categoriesState = new CategoriesState(configStore); + assertFalse(categoriesState.areCategoriesEnabled(mContext, "invalid")); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH) + @DisableFlags(Flags.FLAG_ENABLE_CLOUD_MEDIA_PROVIDER_CAPABILITIES) + public void testAreMediaCategoriesEnabledWithCapabilitiesFlagDisabled() { + final TestConfigStore configStore = new TestConfigStore(); + configStore.setIsModernPickerEnabled(true); + + final CategoriesState categoriesState = new CategoriesState(configStore); + assertFalse(categoriesState.areCategoriesEnabled(mContext, "invalid")); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java index f39da9c09..47ab3df69 100644 --- a/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java +++ b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java @@ -33,6 +33,8 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; +import static org.junit.Assume.assumeTrue; import android.Manifest; import android.app.Instrumentation; @@ -49,6 +51,7 @@ import android.os.CancellationSignal; import android.os.Environment; import android.os.OperationCanceledException; import android.os.ParcelFileDescriptor; +import android.os.UserManager; import android.provider.CloudMediaProviderContract; import android.provider.MediaStore; import android.util.Log; @@ -216,6 +219,7 @@ public class ItemsProviderTest { */ @Test public void testGetCategories_screenshots() throws Exception { + assumeFalse("Not testable in HSUM device", UserManager.isHeadlessSystemUserMode()); Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, /* cancellationSignal*/ null); assertThat(c.getCount()).isEqualTo(0); @@ -254,6 +258,46 @@ public class ItemsProviderTest { /** * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return * correct info about {@link AlbumColumns#ALBUM_ID_SCREENSHOTS}. + * This test is specifically for HSUM devices since creating a top-level screenshots directory + * is not possible. + */ + @Test + public void testGetCategoriesForHSUM_screenshots() throws Exception { + assumeTrue("Test only for HSUM device", UserManager.isHeadlessSystemUserMode()); + Cursor c = mItemsProvider.getAllCategories(/* mimeType */ null, /* userId */ null, + /* cancellationSignal*/ null); + assertThat(c.getCount()).isEqualTo(0); + + // Create 1 image file in Screenshots dir to test + final File screenshotsDir = getScreenshotsDir(); + File imageFile = assertCreateNewImage(screenshotsDir); + // Create 1 image file in Screenshots dir of Downloads dir + final File screenshotsDirInDownloadsDir = getScreenshotsDirFromDownloadsDir(); + File imageFileInScreenshotDirInDownloads = + assertCreateNewImage(screenshotsDirInDownloadsDir); + + // This file should not be included since it's not a valid screenshot directory, even though + // it looks like one. + final File myAlbumScreenshotsDir = + new File(getPicturesDir(), "MyAlbum" + Environment.DIRECTORY_SCREENSHOTS); + final File myAlbumScreenshotsImg = assertCreateNewImage(myAlbumScreenshotsDir); + + try { + assertGetCategoriesMatchMultiple(Arrays.asList( + Pair.create(ALBUM_ID_SCREENSHOTS, 2), + Pair.create(ALBUM_ID_DOWNLOADS, 1) + )); + } finally { + imageFile.delete(); + imageFileInScreenshotDirInDownloads.delete(); + myAlbumScreenshotsImg.delete(); + myAlbumScreenshotsDir.delete(); + } + } + + /** + * Tests {@link ItemsProvider#getAllCategories(String[], UserId, CancellationSignal)} to return + * correct info about {@link AlbumColumns#ALBUM_ID_SCREENSHOTS}. */ @Test public void testGetCategories_not_screenshots() throws Exception { @@ -1423,7 +1467,8 @@ public class ItemsProviderTest { private Uri prepareFileAndGetUri(File file, long lastModifiedTime) throws IOException { ensureParentExists(file.getParentFile()); - assertThat(file.createNewFile()).isTrue(); + file.createNewFile(); + assertThat(file.exists()).isTrue(); // Write 1 byte because 0byte files are not valid in the picker db try (FileOutputStream fos = new FileOutputStream(file)) { diff --git a/tests/src/com/android/providers/media/photopicker/PhotoPickerCloudTestUtils.java b/tests/src/com/android/providers/media/photopicker/PhotoPickerCloudTestUtils.java index 6839a8b20..f013e8587 100644 --- a/tests/src/com/android/providers/media/photopicker/PhotoPickerCloudTestUtils.java +++ b/tests/src/com/android/providers/media/photopicker/PhotoPickerCloudTestUtils.java @@ -17,6 +17,7 @@ package com.android.providers.media.photopicker; import static android.Manifest.permission.READ_DEVICE_CONFIG; +import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG; import static android.Manifest.permission.WRITE_DEVICE_CONFIG; import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; @@ -108,7 +109,8 @@ public class PhotoPickerCloudTestUtils { private static void writeDeviceConfigProp( @NonNull String namespace, @NonNull String name, @NonNull String value) { - getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG); + getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, + WRITE_ALLOWLISTED_DEVICE_CONFIG); try { DeviceConfig.setProperty(namespace, name, value, /* makeDefault= */ false); @@ -118,7 +120,8 @@ public class PhotoPickerCloudTestUtils { } private static void deleteDeviceConfigProp(@NonNull String namespace, @NonNull String name) { - getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG); + getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, + WRITE_ALLOWLISTED_DEVICE_CONFIG); try { if (SdkLevel.isAtLeastU()) { DeviceConfig.deleteProperty(namespace, name); diff --git a/tests/src/com/android/providers/media/photopicker/PickerDataLayerTest.java b/tests/src/com/android/providers/media/photopicker/PickerDataLayerTest.java index d1cfb8e7e..234c1c90c 100644 --- a/tests/src/com/android/providers/media/photopicker/PickerDataLayerTest.java +++ b/tests/src/com/android/providers/media/photopicker/PickerDataLayerTest.java @@ -144,8 +144,7 @@ public class PickerDataLayerTest { initializeTestWorkManager(mContext); final WorkManager workManager = WorkManager.getInstance(mContext); - final PickerSyncManager syncManager = new PickerSyncManager( - workManager, mContext, mConfigStore, /* schedulePeriodicSyncs */ false); + final PickerSyncManager syncManager = new PickerSyncManager(workManager, mContext); mDataLayer = new PickerDataLayer(mContext, mFacade, mController, mConfigStore, syncManager); // Set cloud provider to null to discard diff --git a/tests/src/com/android/providers/media/photopicker/PickerSearchUtils.java b/tests/src/com/android/providers/media/photopicker/PickerSearchUtils.java new file mode 100644 index 000000000..4a1607858 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/PickerSearchUtils.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import android.content.pm.PackageManager; + +public class PickerSearchUtils { + /** + * @return true if search feature is supported on the given device type, otherwise return false. + */ + public static boolean isHardwareSupportedForSearch() { + // The Search feature in Picker is disabled for Watches and IoT devices. + final PackageManager pm = getInstrumentation().getContext().getPackageManager(); + return !pm.hasSystemFeature(PackageManager.FEATURE_EMBEDDED) + && !pm.hasSystemFeature(PackageManager.FEATURE_WATCH); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/SearchStateTest.java b/tests/src/com/android/providers/media/photopicker/SearchStateTest.java new file mode 100644 index 000000000..3719f0282 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/SearchStateTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker; + +import static com.android.providers.media.photopicker.PickerSearchUtils.isHardwareSupportedForSearch; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.Context; +import android.os.Build; +import android.platform.test.annotations.DisableFlags; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; + +import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.providers.media.TestConfigStore; +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.flags.Flags; + +import org.junit.Assume; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; + +// SetFlagsRule.ClassRule is not available in lower Android versions and Search feature will only +// be enabled for Android T+ devices. +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU) +public class SearchStateTest { + @Mock + private PickerSyncController mMockSyncController; + private Context mContext; + private TestConfigStore mConfigStore; + + + @ClassRule + public static final SetFlagsRule.ClassRule mSetFlagsClassRule = new SetFlagsRule.ClassRule(); + @Rule public final SetFlagsRule mSetFlagsRule = mSetFlagsClassRule.createSetFlagsRule(); + + @Before + public void setup() { + initMocks(this); + + Assume.assumeTrue(isHardwareSupportedForSearch()); + PickerSyncController.setInstance(mMockSyncController); + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + mConfigStore = new TestConfigStore(); + mConfigStore.setIsModernPickerEnabled(true); + } + + @EnableFlags({ + Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH, + Flags.FLAG_ENABLE_CLOUD_MEDIA_PROVIDER_CAPABILITIES + }) + @DisableFlags(Flags.FLAG_CLOUD_MEDIA_PROVIDER_SEARCH) + @Test + public void testSearchAPIFlagIsDisabled() { + doReturn(SearchProvider.AUTHORITY) + .when(mMockSyncController).getCloudProviderOrDefault(any()); + + final SearchState searchState = new SearchState(mConfigStore); + final boolean isCloudSearchEnabled = searchState.isCloudSearchEnabled(mContext); + + assertThat(isCloudSearchEnabled).isFalse(); + } + + @EnableFlags({ + Flags.FLAG_CLOUD_MEDIA_PROVIDER_SEARCH, + Flags.FLAG_ENABLE_CLOUD_MEDIA_PROVIDER_CAPABILITIES + }) + @DisableFlags(Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH) + @Test + public void testSearchFeatureFlagIsDisabled() { + doReturn(SearchProvider.AUTHORITY) + .when(mMockSyncController).getCloudProviderOrDefault(any()); + + final SearchState searchState = new SearchState(mConfigStore); + final boolean isCloudSearchEnabled = searchState.isCloudSearchEnabled(mContext); + + assertThat(isCloudSearchEnabled).isFalse(); + } + + @EnableFlags({ + Flags.FLAG_CLOUD_MEDIA_PROVIDER_SEARCH, + Flags.FLAG_ENABLE_CLOUD_MEDIA_PROVIDER_CAPABILITIES, + Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH + }) + @Test + public void testProviderCloudSearchIsEnabled() { + doReturn(SearchProvider.AUTHORITY) + .when(mMockSyncController).getCloudProviderOrDefault(any()); + + final SearchState searchState = new SearchState(mConfigStore); + assertThat(searchState.isCloudSearchEnabled(mContext)).isTrue(); + assertThat(searchState.isCloudSearchEnabled(mContext, SearchProvider.AUTHORITY)).isTrue(); + assertThat(searchState.isCloudSearchEnabled(mContext, "random")).isFalse(); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/data/UserManagerStateTest.java b/tests/src/com/android/providers/media/photopicker/data/UserManagerStateTest.java index 2807da353..f8ea7ca08 100644 --- a/tests/src/com/android/providers/media/photopicker/data/UserManagerStateTest.java +++ b/tests/src/com/android/providers/media/photopicker/data/UserManagerStateTest.java @@ -42,7 +42,7 @@ import java.util.List; @SdkSuppress(minSdkVersion = 31, codeName = "S") public class UserManagerStateTest { - private final UserHandle mPersonalUser = UserHandle.SYSTEM; + private final UserHandle mPersonalUser = UserHandle.of(UserHandle.myUserId()); private final UserHandle mManagedUser = UserHandle.of(100); // like a managed profile private final UserHandle mOtherUser1 = UserHandle.of(101); // like a private profile private final UserHandle mOtherUser2 = UserHandle.of(102); // like a clone profile diff --git a/tests/src/com/android/providers/media/photopicker/data/model/ItemTest.java b/tests/src/com/android/providers/media/photopicker/data/model/ItemTest.java index b13db5fba..1cd26f9ea 100644 --- a/tests/src/com/android/providers/media/photopicker/data/model/ItemTest.java +++ b/tests/src/com/android/providers/media/photopicker/data/model/ItemTest.java @@ -45,6 +45,7 @@ import org.junit.runner.RunWith; import java.time.LocalDate; import java.time.ZoneId; +import java.util.Locale; @RunWith(AndroidJUnit4.class) public class ItemTest { @@ -62,7 +63,7 @@ public class ItemTest { final String mimeType = "image/png"; final long duration = 1000; final Cursor cursor = generateCursorForItem(id, mimeType, dateTaken, generationModified, - duration, _SPECIAL_FORMAT_NONE); + duration, _SPECIAL_FORMAT_NONE, UserHandle.USER_CURRENT); cursor.moveToFirst(); final Item item = new Item(cursor, UserId.CURRENT_USER); @@ -88,10 +89,12 @@ public class ItemTest { final long generationModified = 1L; final String mimeType = "image/png"; final long duration = 1000; + // Some userId other than current user, hence adding 10 to the current user. + final int testUser = UserHandle.USER_CURRENT + 10; final Cursor cursor = generateCursorForItem(id, mimeType, dateTaken, generationModified, - duration, _SPECIAL_FORMAT_NONE); + duration, _SPECIAL_FORMAT_NONE, testUser); cursor.moveToFirst(); - final UserId userId = UserId.of(UserHandle.of(10)); + final UserId userId = UserId.of(UserHandle.of(testUser)); final Item item = new Item(cursor, userId); @@ -101,7 +104,10 @@ public class ItemTest { assertThat(item.getMimeType()).isEqualTo(mimeType); assertThat(item.getDuration()).isEqualTo(duration); assertThat(item.getContentUri()).isEqualTo( - Uri.parse("content://10@com.android.providers.media.photopicker/media/1")); + Uri.parse(String.format( + Locale.ROOT, + "content://%d@com.android.providers.media.photopicker/media/1", + testUser))); assertThat(item.isImage()).isTrue(); @@ -306,7 +312,7 @@ public class ItemTest { } private static Cursor generateCursorForItem(String id, String mimeType, long dateTaken, - long generationModified, long duration, int specialFormat) { + long generationModified, long duration, int specialFormat, int userId) { final MatrixCursor cursor = new MatrixCursor(MediaColumns.ALL_PROJECTION); cursor.addRow(new Object[] { id, @@ -322,7 +328,9 @@ public class ItemTest { 500, // height 0, // orientation "/storage/emulated/0/foo", // data - PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY + PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY, + null, //owner_package_name + userId } ); return cursor; diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java index 502623520..3443b0c1e 100644 --- a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java +++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java @@ -28,6 +28,7 @@ import com.android.internal.logging.UiEventLogger; import com.android.providers.media.TestConfigStore; import com.android.providers.media.photopicker.PhotoPickerActivity; import com.android.providers.media.photopicker.data.ItemsProvider; +import com.android.providers.media.photopicker.data.UserIdManager; import com.android.providers.media.photopicker.metrics.PhotoPickerUiEventLogger; import com.android.providers.media.photopicker.viewmodel.PickerViewModel; @@ -53,6 +54,12 @@ public class PhotoPickerTestActivity extends PhotoPickerActivity { return pickerViewModel; } + @Override + @NonNull + protected UserIdManager getUserIdManager() { + return PhotoPickerBaseTest.getMockUserIdManager(); + } + TestConfigStore getConfigStore() { return mConfigStore; } diff --git a/tests/src/com/android/providers/media/photopicker/sync/MediaInMediaSetsSyncWorkerTest.java b/tests/src/com/android/providers/media/photopicker/sync/MediaInMediaSetsSyncWorkerTest.java new file mode 100644 index 000000000..78ca6fc46 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/MediaInMediaSetsSyncWorkerTest.java @@ -0,0 +1,577 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.sync; + +import static com.android.providers.media.photopicker.PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; +import static com.android.providers.media.photopicker.sync.MediaInMediaSetsSyncWorker.SYNC_COMPLETE_RESUME_KEY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_AUTHORITY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.initializeTestWorkManager; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Bundle; +import android.platform.test.annotations.EnableFlags; +import android.provider.CloudMediaProviderContract; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.Data; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; + +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.flags.Flags; +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.v2.model.MediaSetsSyncRequestParams; +import com.android.providers.media.photopicker.v2.sqlite.MediaSetsDatabaseUtil; +import com.android.providers.media.photopicker.v2.sqlite.PickerSQLConstants; +import com.android.providers.media.photopicker.v2.sqlite.SelectSQLiteQueryBuilder; + +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +@EnableFlags(Flags.FLAG_CLOUD_MEDIA_PROVIDER_SEARCH) +public class MediaInMediaSetsSyncWorkerTest { + @Mock + private PickerSyncController mMockSyncController; + @Mock + private SyncTracker mMockLocalMediaInMediaSetTracker; + @Mock + private SyncTracker mMockCloudMediaInMediaSetTracker; + private Context mContext; + private SQLiteDatabase mDatabase; + private PickerDbFacade mFacade; + + @Before + public void setup() { + initMocks(this); + + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + initializeTestWorkManager(mContext); + + SyncTrackerRegistry.setLocalMediaInMediaSetTracker(mMockLocalMediaInMediaSetTracker); + SyncTrackerRegistry.setCloudMediaInMediaSetTracker(mMockCloudMediaInMediaSetTracker); + PickerSyncController.setInstance(mMockSyncController); + + final File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + final PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + mDatabase = helper.getWritableDatabase(); + mFacade = new PickerDbFacade( + mContext, new PickerSyncLockManager(), LOCAL_PICKER_PROVIDER_AUTHORITY); + mFacade.setCloudProvider(SearchProvider.AUTHORITY); + + doReturn(LOCAL_PICKER_PROVIDER_AUTHORITY).when(mMockSyncController).getLocalProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getCloudProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController) + .getCloudProviderOrDefault(any()); + doReturn(mFacade).when(mMockSyncController).getDbFacade(); + doReturn(new PickerSyncLockManager()).when(mMockSyncController).getPickerSyncLockManager(); + } + + @After + public void teardown() { + mDatabase.close(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + } + + @Test + public void testMediaInMediaSetSyncWithInvalidSyncSource() throws + ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaInMediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, 56, + SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, 1L, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + } + + @Test + public void testMediaInMediaSetSyncWithInvalidMediaSetPickerId() throws + ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaInMediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY, + SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, "", + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + } + + @Test + public void testMediaInMediaSetSyncWithInvalidMediaSetAuthority() throws + ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaInMediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY, + SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, 1L, + SYNC_WORKER_INPUT_AUTHORITY, ""))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + } + + @Test + public void testMediaInMediaSetSyncWithCloudProvider() throws + ExecutionException, InterruptedException { + + String categoryId = "categoryId"; + String auth = String.valueOf(SYNC_CLOUD_ONLY); + long mediaSetPickerId = 1L; + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add("img"); + + int mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, categoryId, auth, mimeTypes); + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, auth); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArrayList( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(mimeTypes)); + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( + mDatabase, requestParams); + if (fetchMediaSetCursor.moveToFirst()) { + mediaSetPickerId = fetchMediaSetCursor.getLong( + fetchMediaSetCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaSetsTableColumns.PICKER_ID.getColumnName())); + } + + final Cursor inputCursor = SearchProvider.getDefaultCloudSearchResults(); + SearchProvider.setMediaSetContents(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaInMediaSetsSyncWorker.class) + .setInputData(new Data( + Map.of( + SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_CLOUD_ONLY, + SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, mediaSetPickerId, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY))) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor mediaInMediaSetsTableCursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.MEDIA_IN_MEDIA_SETS.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(mediaInMediaSetsTableCursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(mediaInMediaSetsTableCursor.getCount()) + .isEqualTo(inputCursor.getCount()); + + if (mediaInMediaSetsTableCursor.moveToFirst() && inputCursor.moveToFirst()) { + + do { + + assertEquals(mediaInMediaSetsTableCursor.getString( + mediaInMediaSetsTableCursor.getColumnIndex( + PickerSQLConstants.MediaInMediaSetsTableColumns.CLOUD_ID + .getColumnName())), + inputCursor.getString( + inputCursor.getColumnIndex( + CloudMediaProviderContract.MediaColumns.ID + )) + ); + + assertEquals((long) mediaInMediaSetsTableCursor.getLong( + mediaInMediaSetsTableCursor.getColumnIndex( + PickerSQLConstants.MediaInMediaSetsTableColumns + .MEDIA_SETS_PICKER_ID.getColumnName())), + mediaSetPickerId + ); + + String mediaStoreUri = inputCursor.getString(inputCursor.getColumnIndex( + CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI + )); + + if (mediaStoreUri == null) { + assertTrue(mediaInMediaSetsTableCursor.isNull( + mediaInMediaSetsTableCursor.getColumnIndex( + PickerSQLConstants.MediaInMediaSetsTableColumns + .LOCAL_ID.getColumnName())) + ); + } else { + String localId = String.valueOf( + ContentUris.parseId(Uri.parse(mediaStoreUri))); + assertEquals(mediaInMediaSetsTableCursor.getString( + mediaInMediaSetsTableCursor.getColumnIndex( + PickerSQLConstants.MediaInMediaSetsTableColumns + .LOCAL_ID.getColumnName())), localId + ); + } + + } while (mediaInMediaSetsTableCursor.moveToNext() && inputCursor.moveToNext()); + } + } + + verify(mMockLocalMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testMediaInMediaSetsSyncLocalProvider() throws + ExecutionException, InterruptedException { + + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getLocalProvider(); + + String categoryId = "categoryId"; + String auth = String.valueOf(SYNC_LOCAL_ONLY); + long mediaSetPickerId = 1L; + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add("img"); + + int mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, categoryId, auth, mimeTypes); + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, auth); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArrayList(MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(mimeTypes)); + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( + mDatabase, requestParams); + if (fetchMediaSetCursor.moveToFirst()) { + mediaSetPickerId = fetchMediaSetCursor.getLong( + fetchMediaSetCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaSetsTableColumns.PICKER_ID.getColumnName())); + } + + final Cursor inputCursor = SearchProvider.getDefaultLocalSearchResults(); + SearchProvider.setMediaSetContents(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaInMediaSetsSyncWorker.class) + .setInputData(new Data( + Map.of( + SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY, + SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, mediaSetPickerId, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY))) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor mediaInMediaSetsTableCursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.MEDIA_IN_MEDIA_SETS.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(mediaInMediaSetsTableCursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(mediaInMediaSetsTableCursor.getCount()) + .isEqualTo(inputCursor.getCount()); + + if (mediaInMediaSetsTableCursor.moveToFirst() && inputCursor.moveToFirst()) { + + do { + + assertEquals(mediaInMediaSetsTableCursor.getString( + mediaInMediaSetsTableCursor.getColumnIndex( + PickerSQLConstants.MediaInMediaSetsTableColumns.LOCAL_ID + .getColumnName())), + inputCursor.getString( + inputCursor.getColumnIndex( + CloudMediaProviderContract.MediaColumns.ID + )) + ); + + assertEquals((long) mediaInMediaSetsTableCursor.getLong( + mediaInMediaSetsTableCursor.getColumnIndex( + PickerSQLConstants.MediaInMediaSetsTableColumns + .MEDIA_SETS_PICKER_ID.getColumnName())), + mediaSetPickerId + ); + + assertTrue(mediaInMediaSetsTableCursor.isNull( + mediaInMediaSetsTableCursor.getColumnIndex( + PickerSQLConstants.MediaInMediaSetsTableColumns + .CLOUD_ID.getColumnName())) + ); + + } while (mediaInMediaSetsTableCursor.moveToNext() && inputCursor.moveToNext()); + } + } + verify(mMockLocalMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + @Ignore("Enable when b/391639613 is fixed") + public void testMediaSetContentsSyncLoop() throws + ExecutionException, InterruptedException { + + String categoryId = "categoryId"; + String auth = String.valueOf(SYNC_CLOUD_ONLY); + long mediaSetPickerId = 1L; + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add("img"); + + int mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, categoryId, auth, mimeTypes); + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, auth); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArrayList( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(mimeTypes)); + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( + mDatabase, requestParams); + if (fetchMediaSetCursor.moveToFirst()) { + mediaSetPickerId = fetchMediaSetCursor.getLong( + fetchMediaSetCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaSetsTableColumns.PICKER_ID.getColumnName())); + } + + final Cursor inputCursor = SearchProvider.getDefaultCloudSearchResults(); + final String repeatPageToken = "LOOP"; + final Bundle bundle = new Bundle(); + bundle.putString(CloudMediaProviderContract.EXTRA_PAGE_TOKEN, repeatPageToken); + inputCursor.setExtras(bundle); + SearchProvider.setMediaSetContents(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaInMediaSetsSyncWorker.class) + .setInputData(new Data( + Map.of( + SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_CLOUD_ONLY, + SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, mediaSetPickerId, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY))) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor mediaInMediaSetsTableCursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.MEDIA_IN_MEDIA_SETS.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(mediaInMediaSetsTableCursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(mediaInMediaSetsTableCursor.getCount()) + .isEqualTo(inputCursor.getCount()); + } + + verify(mMockLocalMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + private Cursor getCursorForMediaSetInsertionTest() { + String[] columns = new String[]{ + CloudMediaProviderContract.MediaSetColumns.ID, + CloudMediaProviderContract.MediaSetColumns.DISPLAY_NAME, + CloudMediaProviderContract.MediaSetColumns.MEDIA_COVER_ID + }; + + final String mediaSetId = "mediaSetId"; + final String displayName = "name"; + final String coverId = "coverId"; + MatrixCursor cursor = new MatrixCursor(columns); + cursor.addRow(new Object[] { mediaSetId, displayName, coverId }); + + return cursor; + } + + @Test + public void testMediaInMediaSetSyncComplete() throws + ExecutionException, InterruptedException { + + String categoryId = "categoryId"; + String auth = String.valueOf(SYNC_CLOUD_ONLY); + long mediaSetPickerId = 1L; + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add("img"); + + int mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, categoryId, auth, mimeTypes); + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, auth); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArrayList(MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(mimeTypes)); + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( + mDatabase, requestParams); + if (fetchMediaSetCursor.moveToFirst()) { + mediaSetPickerId = fetchMediaSetCursor.getLong( + fetchMediaSetCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaSetsTableColumns.PICKER_ID.getColumnName())); + } + MediaSetsDatabaseUtil.updateMediaInMediaSetSyncResumeKey( + mDatabase, mediaSetPickerId, SYNC_COMPLETE_RESUME_KEY); + + final Cursor inputCursor = SearchProvider.getDefaultCloudSearchResults(); + SearchProvider.setMediaSetContents(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaInMediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_CLOUD_ONLY, + SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, mediaSetPickerId, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY))) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor mediaInMediaSetsTableCursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.MEDIA_IN_MEDIA_SETS.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(mediaInMediaSetsTableCursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(mediaInMediaSetsTableCursor.getCount()) + .isEqualTo(0); + + verify(mMockLocalMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudMediaInMediaSetTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + } +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/MediaSetsResetWorkerTest.java b/tests/src/com/android/providers/media/photopicker/sync/MediaSetsResetWorkerTest.java new file mode 100644 index 000000000..17678b08b --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/MediaSetsResetWorkerTest.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.sync; + +import static com.android.providers.media.photopicker.PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.initializeTestWorkManager; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_2; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_3; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_PROVIDER; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_2; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_3; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.assertAddMediaOperation; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getCloudMediaCursor; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.sqlite.SQLiteDatabase; +import android.provider.CloudMediaProviderContract; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; + +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.v2.sqlite.MediaInMediaSetsDatabaseUtil; +import com.android.providers.media.photopicker.v2.sqlite.MediaSetsDatabaseUtil; +import com.android.providers.media.photopicker.v2.sqlite.PickerSQLConstants; +import com.android.providers.media.photopicker.v2.sqlite.SelectSQLiteQueryBuilder; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +public class MediaSetsResetWorkerTest { + + private SQLiteDatabase mDatabase; + private PickerDbFacade mFacade; + private Context mContext; + private final String mMediaSetId = "mediaSetId"; + private final String mCategoryId = "categoryId"; + private final String mAuthority = "auth"; + private final String mMimeType = "img"; + private final String mDisplayName = "name"; + private final String mCoverId = "id"; + @Mock + private PickerSyncController mMockSyncController; + @Mock + private SyncTracker mLocalMediaSetsSyncTracker; + @Mock + private SyncTracker mCloudMediaSetsSyncTracker; + + @Before + public void setup() { + initMocks(this); + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + PickerSyncController.setInstance(mMockSyncController); + SyncTrackerRegistry.setCloudMediaSetsSyncTracker(mCloudMediaSetsSyncTracker); + SyncTrackerRegistry.setLocalMediaSetsSyncTracker(mLocalMediaSetsSyncTracker); + initializeTestWorkManager(mContext); + final File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + final PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + mDatabase = helper.getWritableDatabase(); + mFacade = new PickerDbFacade( + mContext, new PickerSyncLockManager(), LOCAL_PICKER_PROVIDER_AUTHORITY); + mFacade.setCloudProvider(CLOUD_PROVIDER); + doReturn(mFacade).when(mMockSyncController).getDbFacade(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getLocalProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getCloudProvider(); + } + + @After + public void teardown() { + mDatabase.close(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + } + + @Test + public void testMediaSetsAndMediaSetsContentCacheReset() throws + ExecutionException, InterruptedException { + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add(mMimeType); + + int mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, mCategoryId, mAuthority, mimeTypes); + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + Long mediaSetPickerId = 1L; + + final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_3, mediaSetPickerId), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, mediaSetPickerId) + ), CLOUD_PROVIDER); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaSetsResetWorker.class) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursorFromMediaSetTable = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.MEDIA_SETS.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursorFromMediaSetTable) + .isNotNull(); + assertEquals(/*expected*/ 0, /*actual*/ cursorFromMediaSetTable.getCount()); + } + + try (Cursor cursorFromMediaInMediaSetTable = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.MEDIA_IN_MEDIA_SETS.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursorFromMediaInMediaSetTable) + .isNotNull(); + assertEquals(/*expected */ 0, /*actual*/ cursorFromMediaInMediaSetTable.getCount()); + } + } + + private Cursor getCursorForMediaSetInsertionTest() { + String[] columns = new String[]{ + CloudMediaProviderContract.MediaSetColumns.ID, + CloudMediaProviderContract.MediaSetColumns.DISPLAY_NAME, + CloudMediaProviderContract.MediaSetColumns.MEDIA_COVER_ID + }; + + MatrixCursor cursor = new MatrixCursor(columns); + cursor.addRow(new Object[] { mMediaSetId, mDisplayName, mCoverId }); + + return cursor; + } + + private ContentValues getContentValues( + String localId, String cloudId, Long mediaSetPickerId) { + ContentValues contentValues = new ContentValues(); + contentValues.put( + PickerSQLConstants.MediaInMediaSetsTableColumns.CLOUD_ID.getColumnName(), cloudId); + contentValues.put( + PickerSQLConstants.MediaInMediaSetsTableColumns.LOCAL_ID.getColumnName(), localId); + contentValues.put( + PickerSQLConstants.MediaInMediaSetsTableColumns.MEDIA_SETS_PICKER_ID + .getColumnName(), + mediaSetPickerId); + return contentValues; + } + +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/MediaSetsSyncWorkerTest.java b/tests/src/com/android/providers/media/photopicker/sync/MediaSetsSyncWorkerTest.java new file mode 100644 index 000000000..3733cc453 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/MediaSetsSyncWorkerTest.java @@ -0,0 +1,398 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.sync; + +import static com.android.providers.media.photopicker.PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.EXTRA_MIME_TYPES; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_AUTHORITY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_CATEGORY_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.initializeTestWorkManager; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_PROVIDER; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.platform.test.annotations.EnableFlags; +import android.provider.CloudMediaProviderContract; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.Data; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; + +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.flags.Flags; +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.v2.sqlite.PickerSQLConstants; +import com.android.providers.media.photopicker.v2.sqlite.SelectSQLiteQueryBuilder; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +@EnableFlags(Flags.FLAG_CLOUD_MEDIA_PROVIDER_SEARCH) +public class MediaSetsSyncWorkerTest { + + private SQLiteDatabase mDatabase; + private PickerDbFacade mFacade; + private Context mContext; + @Mock + private PickerSyncController mMockSyncController; + @Mock + private SyncTracker mLocalMediaSetsSyncTracker; + @Mock + private SyncTracker mCloudMediaSetsSyncTracker; + private final String mCategoryId = "categoryId"; + private final String[] mMimeTypes = new String[] { "image/*" }; + + @Before + public void setup() { + initMocks(this); + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + initializeTestWorkManager(mContext); + PickerSyncController.setInstance(mMockSyncController); + SyncTrackerRegistry.setCloudMediaSetsSyncTracker(mCloudMediaSetsSyncTracker); + SyncTrackerRegistry.setLocalMediaSetsSyncTracker(mLocalMediaSetsSyncTracker); + final File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + final PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + mDatabase = helper.getWritableDatabase(); + mFacade = new PickerDbFacade( + mContext, new PickerSyncLockManager(), LOCAL_PICKER_PROVIDER_AUTHORITY); + mFacade.setCloudProvider(CLOUD_PROVIDER); + doReturn(mFacade).when(mMockSyncController).getDbFacade(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getLocalProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getCloudProvider(); + } + + @After + public void teardown() { + mDatabase.close(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + } + + @Test + public void testMediaSetsSyncWithInvalidSyncSource() + throws ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, 56, + SYNC_WORKER_INPUT_CATEGORY_ID, mCategoryId, + EXTRA_MIME_TYPES, mMimeTypes))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + } + + @Test + public void testMediaSetsSyncWithMissingSyncSource() + throws ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of( + SYNC_WORKER_INPUT_CATEGORY_ID, mCategoryId, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY, + EXTRA_MIME_TYPES, mMimeTypes))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + } + + @Test + public void testMediaSetsSyncWithInvalidCategoryId() + throws ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of( + SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY, + SYNC_WORKER_INPUT_CATEGORY_ID, "", + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY, + EXTRA_MIME_TYPES, mMimeTypes))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + } + + @Test + public void testMediaSetsSyncWithMissingCategoryId() + throws ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of( + SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY, + EXTRA_MIME_TYPES, mMimeTypes))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + } + + @Test + public void testMediaSetsSyncWithMissingCategoryAuthority() + throws ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of( + SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_CLOUD_ONLY, + SYNC_WORKER_INPUT_CATEGORY_ID, mCategoryId, + EXTRA_MIME_TYPES, mMimeTypes))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + } + + @Test + public void testMediaSetsSyncWithValidSyncSourceAndCategoryIdForCloudAuth() throws + ExecutionException, InterruptedException { + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_CLOUD_ONLY, + SYNC_WORKER_INPUT_CATEGORY_ID, mCategoryId, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY, + EXTRA_MIME_TYPES, mMimeTypes))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + Cursor cursorFromSearchProvider = SearchProvider.sMediaSets; + + try (Cursor cursorFromMediaSetTable = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.MEDIA_SETS.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursorFromMediaSetTable) + .isNotNull(); + assertEquals(cursorFromMediaSetTable.getCount(), cursorFromSearchProvider.getCount()); + + compareMediaSetCursorsForMediaSetProperties( + cursorFromMediaSetTable, cursorFromSearchProvider); + } + + verify(mLocalMediaSetsSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mLocalMediaSetsSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mCloudMediaSetsSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mCloudMediaSetsSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testMediaSetsSyncWithValidSyncSourceAndCategoryIdForLocalAuth() throws + ExecutionException, InterruptedException { + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of( + SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY, + SYNC_WORKER_INPUT_CATEGORY_ID, mCategoryId, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY, + EXTRA_MIME_TYPES, mMimeTypes))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + Cursor cursorFromSearchProvider = SearchProvider.sMediaSets; + + try (Cursor cursorFromMediaSetTable = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.MEDIA_SETS.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursorFromMediaSetTable) + .isNotNull(); + assertEquals(cursorFromMediaSetTable.getCount(), cursorFromSearchProvider.getCount()); + + compareMediaSetCursorsForMediaSetProperties( + cursorFromMediaSetTable, cursorFromSearchProvider); + } + } + + @Test + public void testMediaSetsSyncLoop() throws + ExecutionException, InterruptedException { + // Setup + final String repeatPageToken = "LOOP"; + final Cursor cursorFromSearchProvider = + SearchProvider.getDefaultCursorForMediaSetSyncTest(); + final Bundle bundle = new Bundle(); + bundle.putString(CloudMediaProviderContract.EXTRA_PAGE_TOKEN, repeatPageToken); + cursorFromSearchProvider.setExtras(bundle); + SearchProvider.setMediaSets(cursorFromSearchProvider); + + // Run test + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(MediaSetsSyncWorker.class) + .setInputData( + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_CLOUD_ONLY, + SYNC_WORKER_INPUT_CATEGORY_ID, mCategoryId, + SYNC_WORKER_INPUT_AUTHORITY, SearchProvider.AUTHORITY, + EXTRA_MIME_TYPES, mMimeTypes))) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursorFromMediaSetTable = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.MEDIA_SETS.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursorFromMediaSetTable) + .isNotNull(); + assertEquals(cursorFromMediaSetTable.getCount(), cursorFromSearchProvider.getCount()); + + compareMediaSetCursorsForMediaSetProperties( + cursorFromMediaSetTable, cursorFromSearchProvider); + } + + verify(mLocalMediaSetsSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mLocalMediaSetsSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mCloudMediaSetsSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mCloudMediaSetsSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + + private void compareMediaSetCursorsForMediaSetProperties( + Cursor cursorFromMediaSetTable, Cursor cursorFromSearchProvider) { + + if (cursorFromMediaSetTable.moveToFirst() && cursorFromSearchProvider.moveToFirst()) { + + assertEquals(/*expected*/cursorFromMediaSetTable.getString( + cursorFromMediaSetTable.getColumnIndex( + PickerSQLConstants.MediaSetsTableColumns.CATEGORY_ID + .getColumnName())), + /*actual*/mCategoryId); + + assertEquals(cursorFromMediaSetTable.getString( + cursorFromMediaSetTable.getColumnIndex( + PickerSQLConstants.MediaSetsTableColumns.DISPLAY_NAME + .getColumnName())), + cursorFromSearchProvider.getString( + cursorFromSearchProvider.getColumnIndex( + CloudMediaProviderContract.MediaSetColumns.DISPLAY_NAME + )) + ); + + assertEquals(cursorFromMediaSetTable.getString( + cursorFromMediaSetTable.getColumnIndex( + PickerSQLConstants.MediaSetsTableColumns.MEDIA_SET_ID + .getColumnName())), + cursorFromSearchProvider.getString( + cursorFromSearchProvider.getColumnIndex( + CloudMediaProviderContract.MediaSetColumns.ID + )) + ); + + assertEquals(cursorFromMediaSetTable.getString( + cursorFromMediaSetTable.getColumnIndex( + PickerSQLConstants.MediaSetsTableColumns.COVER_ID + .getColumnName())), + cursorFromSearchProvider.getString( + cursorFromSearchProvider.getColumnIndex( + CloudMediaProviderContract.MediaSetColumns.MEDIA_COVER_ID + )) + ); + } + } +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java b/tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java index 857d14359..60e43e934 100644 --- a/tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java +++ b/tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java @@ -16,10 +16,22 @@ package com.android.providers.media.photopicker.sync; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.EXPIRED_SUGGESTIONS_RESET; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.EXTRA_MIME_TYPES; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_CLOUD_MEDIA_IN_MEDIA_SET_SYNC_WORK_NAME; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.IMMEDIATE_CLOUD_SEARCH_SYNC_WORK_NAME; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SEARCH_RESULTS_FULL_CACHE_RESET; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SEARCH_PARTIAL_CACHE_RESET; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SEARCH_RESULTS_RESET_DELAY; import static com.android.providers.media.photopicker.sync.PickerSyncManager.SHOULD_SYNC_GRANTS; import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_CLOUD_ONLY; import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_AND_CLOUD; import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_AUTHORITY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_CATEGORY_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_RESET_TYPE; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SEARCH_REQUEST_ID; import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; import static com.android.providers.media.util.BackgroundThreadUtils.waitForIdle; @@ -27,9 +39,10 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.MockitoAnnotations.initMocks; @@ -37,6 +50,11 @@ import static org.mockito.MockitoAnnotations.initMocks; import android.content.Context; import android.content.Intent; import android.content.res.Resources; +import android.os.Bundle; +import android.platform.test.annotations.RequiresFlagsDisabled; +import android.platform.test.annotations.RequiresFlagsEnabled; +import android.platform.test.flag.junit.CheckFlagsRule; +import android.platform.test.flag.junit.DeviceFlagsValueProvider; import androidx.work.ExistingPeriodicWorkPolicy; import androidx.work.ExistingWorkPolicy; @@ -44,24 +62,40 @@ import androidx.work.OneTimeWorkRequest; import androidx.work.Operation; import androidx.work.PeriodicWorkRequest; import androidx.work.WorkContinuation; +import androidx.work.WorkInfo; import androidx.work.WorkManager; import androidx.work.WorkRequest; import com.android.providers.media.TestConfigStore; +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.flags.Flags; import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.photopicker.data.PickerSyncRequestExtras; +import com.android.providers.media.photopicker.v2.model.MediaInMediaSetSyncRequestParams; +import com.android.providers.media.photopicker.v2.model.MediaSetsSyncRequestParams; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.TimeUnit; public class PickerSyncManagerTest { + @Rule + public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); + private PickerSyncManager mPickerSyncManager; private TestConfigStore mConfigStore; @Mock @@ -90,12 +124,15 @@ public class PickerSyncManagerTest { mConfigStore = new TestConfigStore(); mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages( "com.hooli.super.awesome.cloudpicker"); + final SettableFuture<List<WorkInfo>> listenableFuture = SettableFuture.create(); + listenableFuture.set(List.of()); + doReturn(listenableFuture).when(mMockWorkManager).getWorkInfosByTag(anyString()); } @Test public void testScheduleEndlessWorker() { - setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + setupPickerSyncManager(/* schedulePeriodicSyncs */ true); // The third call here comes from the EndlessWorker verify(mMockWorkManager, times(1)) @@ -110,10 +147,10 @@ public class PickerSyncManagerTest { } @Test + @RequiresFlagsDisabled(Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH) public void testSchedulePeriodicSyncs() { setupPickerSyncManager(/* schedulePeriodicSyncs */ true); - // The third call here comes from the EndlessWorker verify(mMockWorkManager, times(2)) .enqueueUniquePeriodicWork(anyString(), any(), @@ -147,11 +184,63 @@ public class PickerSyncManagerTest { } @Test + @RequiresFlagsEnabled(Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH) + public void testSchedulePeriodicSyncsWithSearchEnabled() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ true); + + verify(mMockWorkManager, times(3)) + .enqueueUniquePeriodicWork(anyString(), + any(), + mPeriodicWorkRequestArgumentCaptor.capture()); + + final PeriodicWorkRequest periodicWorkRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(0); + assertThat(periodicWorkRequest.getWorkSpec().workerClassName) + .isEqualTo(ProactiveSyncWorker.class.getName()); + assertThat(periodicWorkRequest.getWorkSpec().expedited).isFalse(); + assertThat(periodicWorkRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().id).isNotNull(); + assertThat(periodicWorkRequest.getWorkSpec().constraints.requiresCharging()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().constraints.requiresDeviceIdle()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + + final PeriodicWorkRequest periodicResetRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(1); + assertThat(periodicResetRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaResetWorker.class.getName()); + assertThat(periodicResetRequest.getWorkSpec().expedited).isFalse(); + assertThat(periodicResetRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().id).isNotNull(); + assertThat(periodicResetRequest.getWorkSpec().constraints.requiresCharging()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().constraints.requiresDeviceIdle()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + + final PeriodicWorkRequest searchSuggestionsResetRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(2); + assertThat(searchSuggestionsResetRequest.getWorkSpec().workerClassName) + .isEqualTo(SearchResetWorker.class.getName()); + assertThat(searchSuggestionsResetRequest.getWorkSpec().expedited).isFalse(); + assertThat(searchSuggestionsResetRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(searchSuggestionsResetRequest.getWorkSpec().id).isNotNull(); + assertThat(searchSuggestionsResetRequest.getWorkSpec() + .constraints.requiresCharging()).isTrue(); + assertThat(searchSuggestionsResetRequest.getWorkSpec() + .constraints.requiresDeviceIdle()).isTrue(); + assertThat(searchSuggestionsResetRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_RESET_TYPE, -1)) + .isEqualTo(EXPIRED_SUGGESTIONS_RESET); + } + + @Test + @RequiresFlagsDisabled(Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH) public void testPeriodicWorkIsScheduledOnDeviceConfigChanges() { mConfigStore.disableCloudMediaFeature(); - setupPickerSyncManager(true); // Ensure no syncs have been scheduled yet. @@ -165,8 +254,8 @@ public class PickerSyncManagerTest { waitForIdle(); - // Ensure the syncs are now scheduled. - verify(mMockWorkManager, times(2)) + // Ensure the media and album reset syncs are now scheduled. + verify(mMockWorkManager, atLeast(2)) .enqueueUniquePeriodicWork(anyString(), any(), mPeriodicWorkRequestArgumentCaptor.capture()); @@ -202,13 +291,76 @@ public class PickerSyncManagerTest { mConfigStore.disableCloudMediaFeature(); waitForIdle(); + // There should be 3 invocations, one for cancelling proactive media syncs, + // the other for albums reset and search reset syncs. + verify(mMockWorkManager, times(3)).cancelUniqueWork(anyString()); + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_ENABLE_PHOTOPICKER_SEARCH) + public void testOnDeviceConfigChangesWithSearchEnabled() { + + mConfigStore.disableCloudMediaFeature(); + + setupPickerSyncManager(true); + + // Ensure only search sync is scheduled + verify(mMockWorkManager, times(1)) + .enqueueUniquePeriodicWork(anyString(), + any(), + mPeriodicWorkRequestArgumentCaptor.capture()); + clearInvocations(mMockWorkManager); + + mConfigStore.enableCloudMediaFeatureAndSetAllowedCloudProviderPackages( + "com.hooli.some.cloud.provider"); + + waitForIdle(); + + // Ensure the media and album reset syncs are now scheduled. + verify(mMockWorkManager, times(3)) + .enqueueUniquePeriodicWork(anyString(), + any(), + mPeriodicWorkRequestArgumentCaptor.capture()); + + final PeriodicWorkRequest periodicWorkRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(1); + assertThat(periodicWorkRequest.getWorkSpec().workerClassName) + .isEqualTo(ProactiveSyncWorker.class.getName()); + assertThat(periodicWorkRequest.getWorkSpec().expedited).isFalse(); + assertThat(periodicWorkRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().id).isNotNull(); + assertThat(periodicWorkRequest.getWorkSpec().constraints.requiresCharging()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().constraints.requiresDeviceIdle()).isTrue(); + assertThat(periodicWorkRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + + final PeriodicWorkRequest periodicResetRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(2); + assertThat(periodicResetRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaResetWorker.class.getName()); + assertThat(periodicResetRequest.getWorkSpec().expedited).isFalse(); + assertThat(periodicResetRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().id).isNotNull(); + assertThat(periodicResetRequest.getWorkSpec().constraints.requiresCharging()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().constraints.requiresDeviceIdle()).isTrue(); + assertThat(periodicResetRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + + clearInvocations(mMockWorkManager); + + mConfigStore.disableCloudMediaFeature(); + waitForIdle(); + + // There should be 2 invocations, one for cancelling proactive media syncs, + // the other for albums reset. verify(mMockWorkManager, times(2)).cancelUniqueWork(anyString()); } @Test public void testAdhocProactiveSyncLocalOnly() { setupPickerSyncManager(/* schedulePeriodicSyncs */ false); - reset(mMockWorkManager); mPickerSyncManager.syncMediaProactively(/* localOnly */ true); verify(mMockWorkManager, times(1)) @@ -234,8 +386,6 @@ public class PickerSyncManagerTest { public void testAdhocProactiveSync() { setupPickerSyncManager(/* schedulePeriodicSyncs */ false); - reset(mMockWorkManager); - mPickerSyncManager.syncMediaProactively(/* localOnly */ false); verify(mMockWorkManager, times(1)) .enqueueUniqueWork(anyString(), @@ -261,10 +411,10 @@ public class PickerSyncManagerTest { setupPickerSyncManager(/* schedulePeriodicSyncs */ false); mConfigStore.setIsModernPickerEnabled(true); - reset(mMockWorkManager); mPickerSyncManager.syncMediaImmediately(new PickerSyncRequestExtras(/* albumId */null, /* albumAuthority */ null, /* initLocalDataOnly */ true, - /* callingPackageUid */ 0, /* shouldSyncGrants */ true, null)); + /* callingPackageUid */ 0, /* shouldSyncGrants */ true, null), + mConfigStore); verify(mMockWorkManager, times(2)) .enqueueUniqueWork(anyString(), any(), mOneTimeWorkRequestArgumentCaptor.capture()); @@ -293,10 +443,10 @@ public class PickerSyncManagerTest { mConfigStore.setIsModernPickerEnabled(true); setupPickerSyncManager(/* schedulePeriodicSyncs */ false); - reset(mMockWorkManager); mPickerSyncManager.syncMediaImmediately(new PickerSyncRequestExtras(/* albumId */null, /* albumAuthority */ null, /* initLocalDataOnly */ true, - /* callingPackageUid */ 0, /* shouldSyncGrants */ false, null)); + /* callingPackageUid */ 0, /* shouldSyncGrants */ false, null), + mConfigStore); verify(mMockWorkManager, times(2)) .enqueueUniqueWork(anyString(), any(), mOneTimeWorkRequestArgumentCaptor.capture()); @@ -322,11 +472,10 @@ public class PickerSyncManagerTest { mConfigStore.setIsModernPickerEnabled(true); setupPickerSyncManager(/* schedulePeriodicSyncs */ false); - reset(mMockWorkManager); - mPickerSyncManager.syncMediaImmediately(new PickerSyncRequestExtras(/* albumId */null, /* albumAuthority */ null, /* initLocalDataOnly */ false, - /* callingPackageUid */ 0, /* shouldSyncGrants */ false, null)); + /* callingPackageUid */ 0, /* shouldSyncGrants */ false, null), + mConfigStore); verify(mMockWorkManager, times(3)) .enqueueUniqueWork(anyString(), any(), mOneTimeWorkRequestArgumentCaptor.capture()); @@ -436,6 +585,466 @@ public class PickerSyncManagerTest { .isEqualTo(SYNC_CLOUD_ONLY); } + @Test + public void testSearchResultsLocalSync() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.syncSearchResultsForProvider( + /* searchRequestId */ 10, + SYNC_LOCAL_ONLY, + PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY + ); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), any(), mOneTimeWorkRequestArgumentCaptor.capture()); + + final List<OneTimeWorkRequest> workRequestList = + mOneTimeWorkRequestArgumentCaptor.getAllValues(); + assertThat(workRequestList.size()).isEqualTo(1); + + WorkRequest workRequest = workRequestList.get(0); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(SearchResultsSyncWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isTrue(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().id).isNotNull(); + assertThat(workRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_ONLY); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SEARCH_REQUEST_ID, -1)) + .isEqualTo(10); + } + + @Test + public void testExistingSearchResultsSync() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + final int searchRequestId = 10; + final String authority = PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; + + final SettableFuture<List<WorkInfo>> listenableFuture = SettableFuture.create(); + final WorkInfo workInfo = new WorkInfo( + UUID.randomUUID(), WorkInfo.State.RUNNING, new HashSet<>()); + final String tag = String.format(Locale.ROOT, "%s-%s-%s", + IMMEDIATE_CLOUD_SEARCH_SYNC_WORK_NAME, authority, searchRequestId); + listenableFuture.set(List.of(workInfo)); + doReturn(listenableFuture).when(mMockWorkManager).getWorkInfosByTag(eq(tag)); + + mPickerSyncManager.syncSearchResultsForProvider( + searchRequestId, + SYNC_CLOUD_ONLY, + authority + ); + verify(mMockWorkManager, times(0)) + .enqueueUniqueWork(anyString(), any(), any(OneTimeWorkRequest.class)); + } + + @Test + public void testExistingMediaSetContentsSync() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + final int pickerMediaSetId = 10; + final String authority = PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; + + final SettableFuture<List<WorkInfo>> listenableFuture = SettableFuture.create(); + final WorkInfo workInfo = new WorkInfo( + UUID.randomUUID(), WorkInfo.State.RUNNING, new HashSet<>()); + final String tag = String.format(Locale.ROOT, "%s-%s-%s", + IMMEDIATE_CLOUD_MEDIA_IN_MEDIA_SET_SYNC_WORK_NAME, authority, pickerMediaSetId); + listenableFuture.set(List.of(workInfo)); + doReturn(listenableFuture).when(mMockWorkManager).getWorkInfosByTag(eq(tag)); + + Bundle extras = new Bundle(); + extras.putString(MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_AUTHORITY, + authority); + extras.putLong(MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_PICKER_ID, + pickerMediaSetId); + extras.putStringArrayList("providers", new ArrayList<>(List.of(authority))); + + MediaInMediaSetSyncRequestParams requestParams = new MediaInMediaSetSyncRequestParams( + extras); + mPickerSyncManager.syncMediaInMediaSetForProvider( + requestParams, + SYNC_CLOUD_ONLY + ); + + verify(mMockWorkManager, times(0)) + .enqueueUniqueWork(anyString(), any(), any(OneTimeWorkRequest.class)); + } + + @Test + public void testSearchResultsSyncIsScheduled() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + final int searchRequestId = 10; + final String authority = PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; + + final SettableFuture<List<WorkInfo>> listenableFuture = SettableFuture.create(); + final WorkInfo workInfo = new WorkInfo( + UUID.randomUUID(), WorkInfo.State.RUNNING, new HashSet<>()); + final String tag = String.format(Locale.ROOT, "%s-%s-%s", + IMMEDIATE_CLOUD_SEARCH_SYNC_WORK_NAME, authority, searchRequestId + 1); + listenableFuture.set(List.of(workInfo)); + doReturn(listenableFuture).when(mMockWorkManager).getWorkInfosByTag(eq(tag)); + + mPickerSyncManager.syncSearchResultsForProvider( + searchRequestId, + SYNC_CLOUD_ONLY, + authority + ); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), any(), any(OneTimeWorkRequest.class)); + } + + @Test + public void testMediaSetContentsSyncIsScheduled() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + final int pickerMediaSetId = 10; + final String authority = PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; + + final SettableFuture<List<WorkInfo>> listenableFuture = SettableFuture.create(); + final WorkInfo workInfo = new WorkInfo( + UUID.randomUUID(), WorkInfo.State.RUNNING, new HashSet<>()); + final String tag = String.format(Locale.ROOT, "%s-%s-%s", + IMMEDIATE_CLOUD_MEDIA_IN_MEDIA_SET_SYNC_WORK_NAME, + authority, pickerMediaSetId + 1); + listenableFuture.set(List.of(workInfo)); + doReturn(listenableFuture).when(mMockWorkManager).getWorkInfosByTag(eq(tag)); + + Bundle extras = new Bundle(); + extras.putString(MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_AUTHORITY, + authority); + extras.putLong(MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_PICKER_ID, + pickerMediaSetId); + extras.putStringArrayList("providers", new ArrayList<>(List.of(authority))); + + MediaInMediaSetSyncRequestParams requestParams = new MediaInMediaSetSyncRequestParams( + extras); + mPickerSyncManager.syncMediaInMediaSetForProvider( + requestParams, + SYNC_CLOUD_ONLY + ); + + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), any(), any(OneTimeWorkRequest.class)); + } + + @Test + public void testSearchResultsCloudSync() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.syncSearchResultsForProvider( + /* searchRequestId */ 10, + SYNC_CLOUD_ONLY, + PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY + ); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), any(), mOneTimeWorkRequestArgumentCaptor.capture()); + + final List<OneTimeWorkRequest> workRequestList = + mOneTimeWorkRequestArgumentCaptor.getAllValues(); + assertThat(workRequestList.size()).isEqualTo(1); + + WorkRequest workRequest = workRequestList.get(0); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(SearchResultsSyncWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isTrue(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().id).isNotNull(); + assertThat(workRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_CLOUD_ONLY); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SEARCH_REQUEST_ID, -1)) + .isEqualTo(10); + } + + @Test + public void testMediaSetsSyncLocalProvider() { + setupPickerSyncManager(/*schedulePeriodicSyncs*/ false); + + String categoryId = "id"; + String[] mimeTypes = new String[] { "image/*" }; + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, + SearchProvider.AUTHORITY); + extras.putStringArrayList(MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(Arrays.asList(mimeTypes))); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArrayList("providers", new ArrayList<>(List.of( + PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY))); + + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + + mPickerSyncManager.syncMediaSetsForProvider(requestParams, SYNC_LOCAL_ONLY); + verify(mMockWorkManager, times(1)) + .beginUniqueWork( + anyString(), + any(ExistingWorkPolicy.class), + mOneTimeWorkRequestListArgumentCaptor.capture()); + verify(mMockWorkContinuation, times(1)) + .then(mOneTimeWorkRequestListArgumentCaptor.capture()); + verify(mMockWorkContinuation).enqueue(); + + final List<List<OneTimeWorkRequest>> workRequestList = + mOneTimeWorkRequestListArgumentCaptor.getAllValues(); + assertThat(workRequestList.size()).isEqualTo(2); + + WorkRequest resetRequest = workRequestList.get(0).get(0); + assertThat(resetRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaSetsResetWorker.class.getName()); + assertThat(resetRequest.getWorkSpec().expedited).isTrue(); + assertThat(resetRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(resetRequest.getWorkSpec().id).isNotNull(); + assertThat(resetRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + + WorkRequest syncRequest = workRequestList.get(1).get(0); + assertThat(syncRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaSetsSyncWorker.class.getName()); + assertThat(syncRequest.getWorkSpec().expedited).isTrue(); + assertThat(syncRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(syncRequest.getWorkSpec().id).isNotNull(); + assertThat(syncRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(syncRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_ONLY); + assertThat(syncRequest.getWorkSpec().input + .getString(SYNC_WORKER_INPUT_CATEGORY_ID)) + .isEqualTo(categoryId); + assertThat(syncRequest.getWorkSpec().input + .getString(SYNC_WORKER_INPUT_AUTHORITY)) + .isEqualTo(SearchProvider.AUTHORITY); + assertThat(syncRequest.getWorkSpec().input + .getStringArray(EXTRA_MIME_TYPES)) + .isEqualTo(mimeTypes); + } + + @Test + public void testMediaSetsSyncCloudProvider() { + setupPickerSyncManager(/*schedulePeriodicSyncs*/ false); + + String categoryId = "id"; + String[] mimeTypes = new String[] { "image/*" }; + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, + SearchProvider.AUTHORITY); + extras.putStringArrayList(MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(Arrays.asList(mimeTypes))); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArrayList("providers", new ArrayList<>(List.of( + SearchProvider.AUTHORITY))); + + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + + mPickerSyncManager.syncMediaSetsForProvider(requestParams, SYNC_CLOUD_ONLY); + verify(mMockWorkManager, times(1)) + .beginUniqueWork( + anyString(), + any(ExistingWorkPolicy.class), + mOneTimeWorkRequestListArgumentCaptor.capture()); + verify(mMockWorkContinuation, times(1)) + .then(mOneTimeWorkRequestListArgumentCaptor.capture()); + verify(mMockWorkContinuation).enqueue(); + + final List<List<OneTimeWorkRequest>> workRequestList = + mOneTimeWorkRequestListArgumentCaptor.getAllValues(); + assertThat(workRequestList.size()).isEqualTo(2); + + WorkRequest resetRequest = workRequestList.get(0).get(0); + assertThat(resetRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaSetsResetWorker.class.getName()); + assertThat(resetRequest.getWorkSpec().expedited).isTrue(); + assertThat(resetRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(resetRequest.getWorkSpec().id).isNotNull(); + assertThat(resetRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + + WorkRequest syncRequest = workRequestList.get(1).get(0); + assertThat(syncRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaSetsSyncWorker.class.getName()); + assertThat(syncRequest.getWorkSpec().expedited).isTrue(); + assertThat(syncRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(syncRequest.getWorkSpec().id).isNotNull(); + assertThat(syncRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(syncRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_CLOUD_ONLY); + assertThat(syncRequest.getWorkSpec().input + .getString(SYNC_WORKER_INPUT_CATEGORY_ID)) + .isEqualTo(categoryId); + assertThat(syncRequest.getWorkSpec().input + .getString(SYNC_WORKER_INPUT_AUTHORITY)) + .isEqualTo(SearchProvider.AUTHORITY); + assertThat(syncRequest.getWorkSpec().input + .getStringArray(EXTRA_MIME_TYPES)) + .isEqualTo(mimeTypes); + } + + @Test + public void testMediaInMediaSetSyncLocalProvider() { + setupPickerSyncManager(/*schedulePeriodicSyncs*/ false); + + Long mediaSetPickerId = 1L; + Bundle extras = new Bundle(); + extras.putString(MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_AUTHORITY, + SearchProvider.AUTHORITY); + extras.putLong(MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_PICKER_ID, + mediaSetPickerId); + extras.putStringArrayList("providers", new ArrayList<>(List.of( + PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY))); + + MediaInMediaSetSyncRequestParams requestParams = new MediaInMediaSetSyncRequestParams( + extras); + + mPickerSyncManager.syncMediaInMediaSetForProvider(requestParams, SYNC_LOCAL_ONLY); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), any(), mOneTimeWorkRequestArgumentCaptor.capture()); + + final List<OneTimeWorkRequest> workRequestList = + mOneTimeWorkRequestArgumentCaptor.getAllValues(); + assertThat(workRequestList.size()).isEqualTo(1); + + WorkRequest workRequest = workRequestList.get(0); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaInMediaSetsSyncWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isTrue(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().id).isNotNull(); + assertThat(workRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_ONLY); + assertThat(workRequest.getWorkSpec().input + .getLong(SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, -1)) + .isEqualTo(mediaSetPickerId); + assertThat(workRequest.getWorkSpec().input + .getString(SYNC_WORKER_INPUT_AUTHORITY)) + .isEqualTo(SearchProvider.AUTHORITY); + } + + @Test + public void testMediaInMediaSetSyncCloudProvider() { + setupPickerSyncManager(/*schedulePeriodicSyncs*/ false); + + Long mediaSetPickerId = 1L; + Bundle extras = new Bundle(); + extras.putString( + MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_AUTHORITY, + SearchProvider.AUTHORITY); + extras.putLong( + MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_PICKER_ID, + mediaSetPickerId); + extras.putStringArrayList("providers", new ArrayList<>(List.of( + PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY))); + + MediaInMediaSetSyncRequestParams requestParams = new MediaInMediaSetSyncRequestParams( + extras); + + mPickerSyncManager.syncMediaInMediaSetForProvider(requestParams, SYNC_CLOUD_ONLY); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), any(), mOneTimeWorkRequestArgumentCaptor.capture()); + + final List<OneTimeWorkRequest> workRequestList = + mOneTimeWorkRequestArgumentCaptor.getAllValues(); + assertThat(workRequestList.size()).isEqualTo(1); + + WorkRequest workRequest = workRequestList.get(0); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(MediaInMediaSetsSyncWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isTrue(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().id).isNotNull(); + assertThat(workRequest.getWorkSpec().constraints.requiresBatteryNotLow()).isFalse(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_CLOUD_ONLY); + assertThat(workRequest.getWorkSpec().input + .getLong(SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, -1)) + .isEqualTo(mediaSetPickerId); + assertThat(workRequest.getWorkSpec().input + .getString(SYNC_WORKER_INPUT_AUTHORITY)) + .isEqualTo(SearchProvider.AUTHORITY); + } + + @Test + public void testResetCloudSearchResults() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.resetCloudSearchCache(null); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), + any(), + mOneTimeWorkRequestArgumentCaptor.capture()); + + final OneTimeWorkRequest workRequest = mOneTimeWorkRequestArgumentCaptor.getValue(); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(SearchResetWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isTrue(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_CLOUD_ONLY); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_RESET_TYPE, -1)) + .isEqualTo(SEARCH_PARTIAL_CACHE_RESET); + } + + @Test + public void testDelayedResetSearchCache() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.delayedResetSearchCache(); + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), + any(), + mOneTimeWorkRequestArgumentCaptor.capture()); + + final OneTimeWorkRequest workRequest = mOneTimeWorkRequestArgumentCaptor.getValue(); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(SearchResetWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isFalse(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isFalse(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_RESET_TYPE, -1)) + .isEqualTo(SEARCH_RESULTS_FULL_CACHE_RESET); + assertThat(workRequest.getWorkSpec().initialDelay) + .isEqualTo(TimeUnit.MINUTES.toMillis(SEARCH_RESULTS_RESET_DELAY)); + assertThat(workRequest.getWorkSpec().constraints.requiresDeviceIdle()) + .isTrue(); + + } + + @Test + public void testScheduleClearExpiredSuggestions() { + setupPickerSyncManager(/* schedulePeriodicSyncs */ false); + + mPickerSyncManager.schedulePeriodicSearchSuggestionsReset(); + verify(mMockWorkManager, times(1)) + .enqueueUniquePeriodicWork(anyString(), + any(), + mPeriodicWorkRequestArgumentCaptor.capture()); + + final PeriodicWorkRequest workRequest = + mPeriodicWorkRequestArgumentCaptor.getAllValues().get(0); + assertThat(workRequest.getWorkSpec().workerClassName) + .isEqualTo(SearchResetWorker.class.getName()); + assertThat(workRequest.getWorkSpec().expedited).isFalse(); + assertThat(workRequest.getWorkSpec().isPeriodic()).isTrue(); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) + .isEqualTo(SYNC_LOCAL_AND_CLOUD); + assertThat(workRequest.getWorkSpec().input + .getInt(SYNC_WORKER_INPUT_RESET_TYPE, -1)) + .isEqualTo(EXPIRED_SUGGESTIONS_RESET); + assertThat(workRequest.getWorkSpec().constraints.requiresCharging()).isTrue(); + assertThat(workRequest.getWorkSpec().constraints.requiresDeviceIdle()).isTrue(); + } + private void setupPickerSyncManager(boolean schedulePeriodicSyncs) { doReturn(mMockOperation).when(mMockWorkManager) .enqueueUniquePeriodicWork(anyString(), @@ -456,8 +1065,11 @@ public class PickerSyncManagerTest { doReturn(mMockOperation).when(mMockWorkContinuation).enqueue(); doReturn(mMockFuture).when(mMockOperation).getResult(); - mPickerSyncManager = - new PickerSyncManager(mMockWorkManager, mMockContext, - mConfigStore, schedulePeriodicSyncs); + mPickerSyncManager = new PickerSyncManager(mMockWorkManager, mMockContext); + if (schedulePeriodicSyncs) { + mPickerSyncManager.schedulePeriodicSync( + mConfigStore, /* periodicSyncInitialDelay */ 0L); + } + waitForIdle(); } } diff --git a/tests/src/com/android/providers/media/photopicker/sync/SearchResultsSyncWorkerTest.java b/tests/src/com/android/providers/media/photopicker/sync/SearchResultsSyncWorkerTest.java new file mode 100644 index 000000000..ca8112d5f --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/sync/SearchResultsSyncWorkerTest.java @@ -0,0 +1,1002 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.sync; + +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_ALBUM; +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_FACE; + +import static com.android.providers.media.photopicker.PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_LOCAL_ONLY; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SEARCH_REQUEST_ID; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; +import static com.android.providers.media.photopicker.sync.SearchResultsSyncWorker.SYNC_COMPLETE_RESUME_KEY; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getCloudSearchResultsSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getInvalidSearchResultsSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.getLocalSearchResultsSyncInputData; +import static com.android.providers.media.photopicker.sync.SyncWorkerTestUtils.initializeTestWorkManager; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.annotation.NonNull; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Bundle; +import android.provider.CloudMediaProviderContract; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.work.Data; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; + +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.SearchState; +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.v2.model.SearchRequest; +import com.android.providers.media.photopicker.v2.model.SearchSuggestionRequest; +import com.android.providers.media.photopicker.v2.model.SearchTextRequest; +import com.android.providers.media.photopicker.v2.sqlite.PickerSQLConstants; +import com.android.providers.media.photopicker.v2.sqlite.SearchRequestDatabaseUtil; +import com.android.providers.media.photopicker.v2.sqlite.SelectSQLiteQueryBuilder; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +public class SearchResultsSyncWorkerTest { + @Mock + private PickerSyncController mMockSyncController; + @Mock + private SyncTracker mMockLocalSearchSyncTracker; + @Mock + private SyncTracker mMockCloudSearchSyncTracker; + @Mock + private SearchState mSearchState; + private Context mContext; + private SQLiteDatabase mDatabase; + private PickerDbFacade mFacade; + private String mLocalAuthority; + private String mCloudAuthority; + + @Before + public void setup() { + initMocks(this); + + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + initializeTestWorkManager(mContext); + + SyncTrackerRegistry.setLocalSearchSyncTracker(mMockLocalSearchSyncTracker); + SyncTrackerRegistry.setCloudSearchSyncTracker(mMockCloudSearchSyncTracker); + PickerSyncController.setInstance(mMockSyncController); + + final File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + final PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + + mLocalAuthority = LOCAL_PICKER_PROVIDER_AUTHORITY; + mCloudAuthority = SearchProvider.AUTHORITY; + + mDatabase = helper.getWritableDatabase(); + mFacade = new PickerDbFacade( + mContext, new PickerSyncLockManager(), mLocalAuthority); + mFacade.setCloudProvider(mCloudAuthority); + + doReturn(mLocalAuthority).when(mMockSyncController).getLocalProvider(); + doReturn(mCloudAuthority).when(mMockSyncController).getCloudProvider(); + doReturn(mCloudAuthority).when(mMockSyncController) + .getCloudProviderOrDefault(any()); + doReturn(mFacade).when(mMockSyncController).getDbFacade(); + doReturn(mSearchState).when(mMockSyncController).getSearchState(); + doReturn(true).when(mSearchState).isCloudSearchEnabled(any()); + doReturn(true).when(mSearchState).isCloudSearchEnabled(any(), any()); + doReturn(new PickerSyncLockManager()).when(mMockSyncController).getPickerSyncLockManager(); + } + + @Test + public void testInvalidSyncSource() + throws ExecutionException, InterruptedException { + // Setup + SearchTextRequest searchRequest = new SearchTextRequest( + null, + "search text" + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData( + getInvalidSearchResultsSyncInputData( + searchRequestId, mCloudAuthority)) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testMissingSearchRequestId() + throws ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData( + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY))) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + public void testInvalidSearchRequestId() + throws ExecutionException, InterruptedException { + // Setup + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getLocalSearchResultsSyncInputData( + /* searchRequestId */ 10, mLocalAuthority)) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + public void testMissingInputAuthority() + throws ExecutionException, InterruptedException { + // Setup + SearchTextRequest searchRequest = new SearchTextRequest( + null, + "search text" + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData( + new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY, + SYNC_WORKER_INPUT_SEARCH_REQUEST_ID, searchRequestId))) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + public void testIncorrectInputAuthority() + throws ExecutionException, InterruptedException { + // Setup + SearchTextRequest searchRequest = new SearchTextRequest( + null, + "search text" + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getCloudSearchResultsSyncInputData( + searchRequestId, "random.authority")) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testInvalidAlbumSuggestionsSearchRequestId() + throws ExecutionException, InterruptedException { + // Setup cloud search results sync for local album + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + null, + "search text", + "media-set-id", + mCloudAuthority, + SEARCH_SUGGESTION_ALBUM + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getLocalSearchResultsSyncInputData( + searchRequestId, mLocalAuthority)) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.FAILED); + } + + @Test + public void testTextSearchSyncWithCloudProvider() + throws ExecutionException, InterruptedException { + // Setup + SearchTextRequest searchRequest = new SearchTextRequest( + null, + "search text" + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + final Cursor inputCursor = SearchProvider.getDefaultCloudSearchResults(); + SearchProvider.setSearchResults(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getCloudSearchResultsSyncInputData( + searchRequestId, mCloudAuthority)) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(inputCursor.getCount()); + + if (cursor.moveToFirst() && inputCursor.moveToFirst()) { + do { + final ContentValues dbValues = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(cursor, dbValues); + + final ContentValues inputValues = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(inputCursor, inputValues); + + assertWithMessage("Cloud id is not as expected") + .that(dbValues.getAsString(PickerSQLConstants + .SearchResultMediaTableColumns.CLOUD_ID.getColumnName())) + .isEqualTo(inputValues.get(CloudMediaProviderContract.MediaColumns.ID)); + + assertWithMessage("Search request id is not as expected") + .that(dbValues.getAsInteger( + PickerSQLConstants.SearchResultMediaTableColumns + .SEARCH_REQUEST_ID.getColumnName())) + .isEqualTo(searchRequestId); + + final String inputMediaStoreUri = inputValues + .getAsString(CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI); + if (inputMediaStoreUri == null) { + assertWithMessage("Local id is not null") + .that(dbValues.getAsInteger( + PickerSQLConstants.SearchResultMediaTableColumns + .LOCAL_ID.getColumnName())) + .isNull(); + } else { + assertWithMessage("Local id is not as expected") + .that(dbValues.getAsInteger( + PickerSQLConstants.SearchResultMediaTableColumns + .LOCAL_ID.getColumnName())) + .isEqualTo(ContentUris.parseId(Uri.parse(inputMediaStoreUri))); + } + } while (cursor.moveToNext() && inputCursor.moveToNext()); + } + } + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testTextSearchSyncWithLocalProvider() + throws ExecutionException, InterruptedException { + // Setup + SearchTextRequest searchRequest = new SearchTextRequest( + null, + "search text" + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + mLocalAuthority = SearchProvider.AUTHORITY; + doReturn(mLocalAuthority).when(mMockSyncController).getLocalProvider(); + + final Cursor inputCursor = SearchProvider.getDefaultLocalSearchResults(); + SearchProvider.setSearchResults(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getLocalSearchResultsSyncInputData( + searchRequestId, mLocalAuthority)) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(inputCursor.getCount()); + + if (cursor.moveToFirst() && inputCursor.moveToFirst()) { + do { + final ContentValues dbValues = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(cursor, dbValues); + + final ContentValues inputValues = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(inputCursor, inputValues); + + assertWithMessage("Local id is not as expected") + .that(dbValues.getAsString(PickerSQLConstants + .SearchResultMediaTableColumns.LOCAL_ID.getColumnName())) + .isEqualTo(inputValues.get(CloudMediaProviderContract.MediaColumns.ID)); + + assertWithMessage("Cloud id is not null") + .that(dbValues.getAsString(PickerSQLConstants + .SearchResultMediaTableColumns.CLOUD_ID.getColumnName())) + .isNull(); + + assertWithMessage("Search request id is not as expected") + .that(dbValues.getAsInteger( + PickerSQLConstants.SearchResultMediaTableColumns + .SEARCH_REQUEST_ID.getColumnName())) + .isEqualTo(searchRequestId); + } while (cursor.moveToNext() && inputCursor.moveToNext()); + } + } + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + public void testSuggestionSearchSyncWithCloudProvider() + throws ExecutionException, InterruptedException { + // Setup + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + null, + "search text", + "media-set-id", + mLocalAuthority, + SEARCH_SUGGESTION_FACE + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + final Cursor inputCursor = SearchProvider.getDefaultCloudSearchResults(); + SearchProvider.setSearchResults(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getCloudSearchResultsSyncInputData( + searchRequestId, mCloudAuthority)) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(inputCursor.getCount()); + + if (cursor.moveToFirst() && inputCursor.moveToFirst()) { + do { + final ContentValues dbValues = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(cursor, dbValues); + + final ContentValues inputValues = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(inputCursor, inputValues); + + assertWithMessage("Cloud id is not as expected") + .that(dbValues.getAsString(PickerSQLConstants + .SearchResultMediaTableColumns.CLOUD_ID.getColumnName())) + .isEqualTo(inputValues.get(CloudMediaProviderContract.MediaColumns.ID)); + + assertWithMessage("Search request id is not as expected") + .that(dbValues.getAsInteger( + PickerSQLConstants.SearchResultMediaTableColumns + .SEARCH_REQUEST_ID.getColumnName())) + .isEqualTo(searchRequestId); + + final String inputMediaStoreUri = inputValues + .getAsString(CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI); + if (inputMediaStoreUri == null) { + assertWithMessage("Local id is not null") + .that(dbValues.getAsInteger( + PickerSQLConstants.SearchResultMediaTableColumns + .LOCAL_ID.getColumnName())) + .isNull(); + } else { + assertWithMessage("Local id is not as expected") + .that(dbValues.getAsInteger( + PickerSQLConstants.SearchResultMediaTableColumns + .LOCAL_ID.getColumnName())) + .isEqualTo(ContentUris.parseId(Uri.parse(inputMediaStoreUri))); + } + } while (cursor.moveToNext() && inputCursor.moveToNext()); + } + } + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testSuggestionSearchSyncWithLocalProvider() + throws ExecutionException, InterruptedException { + // Setup + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + null, + "search text", + "media-set-id", + mCloudAuthority, + SEARCH_SUGGESTION_FACE, + "Random-local-resume-key", + "local-authority", + "Random-cloud-resume-key", + mCloudAuthority + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + mLocalAuthority = SearchProvider.AUTHORITY; + doReturn(mLocalAuthority).when(mMockSyncController).getLocalProvider(); + + final Cursor inputCursor = SearchProvider.getDefaultLocalSearchResults(); + SearchProvider.setSearchResults(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getLocalSearchResultsSyncInputData( + searchRequestId, mLocalAuthority)) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(inputCursor.getCount()); + + if (cursor.moveToFirst() && inputCursor.moveToFirst()) { + do { + final ContentValues dbValues = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(cursor, dbValues); + + final ContentValues inputValues = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(inputCursor, inputValues); + + assertWithMessage("Local id is not as expected") + .that(dbValues.getAsString(PickerSQLConstants + .SearchResultMediaTableColumns.LOCAL_ID.getColumnName())) + .isEqualTo(inputValues.get(CloudMediaProviderContract.MediaColumns.ID)); + + assertWithMessage("Cloud id is not null") + .that(dbValues.getAsString(PickerSQLConstants + .SearchResultMediaTableColumns.CLOUD_ID.getColumnName())) + .isNull(); + + assertWithMessage("Search request id is not as expected") + .that(dbValues.getAsInteger( + PickerSQLConstants.SearchResultMediaTableColumns + .SEARCH_REQUEST_ID.getColumnName())) + .isEqualTo(searchRequestId); + } while (cursor.moveToNext() && inputCursor.moveToNext()); + } + } + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + } + + @Test + public void testSyncWasAlreadyComplete() + throws ExecutionException, InterruptedException { + // Setup + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + null, + "search text", + "media-set-id", + mLocalAuthority, + SEARCH_SUGGESTION_FACE, + "Random-local-resume-key", + "local-authority", + SYNC_COMPLETE_RESUME_KEY, + mCloudAuthority + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + final Cursor inputCursor = SearchProvider.getDefaultCloudSearchResults(); + SearchProvider.setSearchResults(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getCloudSearchResultsSyncInputData( + searchRequestId, mCloudAuthority)) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(0); + } + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + @Test + public void testCloudSyncResumeInfoIsCleared() + throws ExecutionException, InterruptedException { + // Setup + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + null, + "search text", + "media-set-id", + mLocalAuthority, + SEARCH_SUGGESTION_FACE + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + final Cursor inputCursor = SearchProvider.getDefaultCloudSearchResults(); + SearchProvider.setSearchResults(inputCursor); + + // Run the search results worker to sync with SearchProvider. + final OneTimeWorkRequest request1 = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getCloudSearchResultsSyncInputData( + searchRequestId, mCloudAuthority)) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request1).getResult().get(); + + // Verify that sync happened + final WorkInfo workInfo1 = workManager.getWorkInfoById(request1.getId()).get(); + assertThat(workInfo1.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(inputCursor.getCount()); + } + + final SearchRequest searchRequest1 = + SearchRequestDatabaseUtil.getSearchRequestDetails(mDatabase, searchRequestId); + assertWithMessage("Search details are null") + .that(searchRequest1) + .isNotNull(); + assertWithMessage("Cloud sync authority is not as expected") + .that(searchRequest1.getCloudAuthority()) + .isEqualTo(SearchProvider.AUTHORITY); + assertWithMessage("Cloud sync resume key is not as expected") + .that(searchRequest1.getCloudSyncResumeKey()) + .isEqualTo(SYNC_COMPLETE_RESUME_KEY); + + // Run the search results worker to sync with a random provider + final String newCloudAuthority = "random.authority"; + doReturn(newCloudAuthority).when(mMockSyncController).getCloudProvider(); + doReturn(newCloudAuthority).when(mMockSyncController).getCloudProviderOrDefault(any()); + final OneTimeWorkRequest request2 = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getCloudSearchResultsSyncInputData( + searchRequestId, newCloudAuthority)) + .build(); + + workManager.enqueue(request2).getResult().get(); + + // Verify that the database was cleared. + final WorkInfo workInfo2 = workManager.getWorkInfoById(request2.getId()).get(); + assertThat(workInfo2.getState()).isEqualTo(WorkInfo.State.FAILED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(0); + } + + final SearchRequest searchRequest2 = + SearchRequestDatabaseUtil.getSearchRequestDetails(mDatabase, searchRequestId); + assertWithMessage("Search details are null") + .that(searchRequest2) + .isNotNull(); + assertWithMessage("Cloud sync authority is not as expected") + .that(searchRequest2.getCloudAuthority()) + .isNull(); + assertWithMessage("Cloud sync resume key is not as expected") + .that(searchRequest2.getCloudSyncResumeKey()) + .isNull(); + } + + @Test + public void testLocalSyncResumeInfoIsCleared() + throws ExecutionException, InterruptedException { + // Setup + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + null, + "search text", + "media-set-id", + mLocalAuthority, + SEARCH_SUGGESTION_FACE + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + mLocalAuthority = SearchProvider.AUTHORITY; + doReturn(mLocalAuthority).when(mMockSyncController).getLocalProvider(); + + final Cursor inputCursor = SearchProvider.getDefaultLocalSearchResults(); + SearchProvider.setSearchResults(inputCursor); + + // Run the search results worker to sync with SearchProvider. + final OneTimeWorkRequest request1 = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getLocalSearchResultsSyncInputData( + searchRequestId, mLocalAuthority)) + .build(); + + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request1).getResult().get(); + + // Verify that sync happened + final WorkInfo workInfo1 = workManager.getWorkInfoById(request1.getId()).get(); + assertThat(workInfo1.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(inputCursor.getCount()); + } + + final SearchRequest searchRequest1 = + SearchRequestDatabaseUtil.getSearchRequestDetails(mDatabase, searchRequestId); + assertWithMessage("Search details are null") + .that(searchRequest1) + .isNotNull(); + assertWithMessage("Local sync authority is not as expected") + .that(searchRequest1.getLocalAuthority()) + .isEqualTo(mLocalAuthority); + assertWithMessage("Local sync resume key is not as expected") + .that(searchRequest1.getLocalSyncResumeKey()) + .isEqualTo(SYNC_COMPLETE_RESUME_KEY); + + // Run the search results worker to sync with a random provider + final String newLocalAuthority = "random.authority"; + doReturn(newLocalAuthority).when(mMockSyncController).getLocalProvider(); + final OneTimeWorkRequest request2 = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getLocalSearchResultsSyncInputData( + searchRequestId, newLocalAuthority)) + .build(); + workManager.enqueue(request2).getResult().get(); + + // Verify that the database was cleared. + final WorkInfo workInfo2 = workManager.getWorkInfoById(request2.getId()).get(); + assertThat(workInfo2.getState()).isEqualTo(WorkInfo.State.FAILED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(0); + } + + final SearchRequest searchRequest2 = + SearchRequestDatabaseUtil.getSearchRequestDetails(mDatabase, searchRequestId); + assertWithMessage("Search details are null") + .that(searchRequest2) + .isNotNull(); + assertWithMessage("Local sync authority is not as expected") + .that(searchRequest2.getLocalAuthority()) + .isNull(); + assertWithMessage("Local sync resume key is not as expected") + .that(searchRequest2.getLocalSyncResumeKey()) + .isNull(); + } + + @Test + public void testSuggestionSearchSyncLoop() + throws ExecutionException, InterruptedException { + // Setup + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + null, + "search text", + "media-set-id", + mLocalAuthority, + SEARCH_SUGGESTION_FACE + ); + final int searchRequestId = saveSearchRequest(searchRequest); + + final Cursor inputCursor = SearchProvider.getDefaultCloudSearchResults(); + final String repeatPageToken = "LOOP"; + final Bundle bundle = new Bundle(); + bundle.putString(CloudMediaProviderContract.EXTRA_PAGE_TOKEN, repeatPageToken); + inputCursor.setExtras(bundle); + SearchProvider.setSearchResults(inputCursor); + + final OneTimeWorkRequest request = + new OneTimeWorkRequest.Builder(SearchResultsSyncWorker.class) + .setInputData(getCloudSearchResultsSyncInputData( + searchRequestId, mCloudAuthority)) + .build(); + + // Test run + final WorkManager workManager = WorkManager.getInstance(mContext); + workManager.enqueue(request).getResult().get(); + + // Verify + final WorkInfo workInfo = workManager.getWorkInfoById(request.getId()).get(); + assertThat(workInfo.getState()).isEqualTo(WorkInfo.State.SUCCEEDED); + + try (Cursor cursor = mDatabase.rawQuery( + new SelectSQLiteQueryBuilder(mDatabase).setTables( + PickerSQLConstants.Table.SEARCH_RESULT_MEDIA.name() + ).buildQuery(), null + )) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(inputCursor.getCount()); + } + + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockLocalSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .markSyncCompleted(any()); + + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 0)) + .createSyncFuture(any()); + verify(mMockCloudSearchSyncTracker, times(/* wantedNumberOfInvocations */ 1)) + .markSyncCompleted(any()); + } + + /** + * Saves the given search request in DB and asserts that it was saved. + * Returns the generated search request id. + */ + private int saveSearchRequest(@NonNull SearchRequest searchRequest) { + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest); + final int searchRequestId = + SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest); + + assertWithMessage("Could not find search request is the database " + searchRequest) + .that(searchRequestId) + .isNotEqualTo(-1); + + return searchRequestId; + } +} diff --git a/tests/src/com/android/providers/media/photopicker/sync/SyncTrackerTests.java b/tests/src/com/android/providers/media/photopicker/sync/SyncTrackerTests.java index 1eac73a78..574580963 100644 --- a/tests/src/com/android/providers/media/photopicker/sync/SyncTrackerTests.java +++ b/tests/src/com/android/providers/media/photopicker/sync/SyncTrackerTests.java @@ -50,6 +50,19 @@ public class SyncTrackerTests { } @Test + public void testMarkAllSyncsComplete() { + SyncTracker syncTracker = new SyncTracker(); + syncTracker.createSyncFuture(UUID.randomUUID()); + syncTracker.createSyncFuture(UUID.randomUUID()); + syncTracker.createSyncFuture(UUID.randomUUID()); + Collection<CompletableFuture<Object>> futures = syncTracker.pendingSyncFutures(); + assertThat(futures.size()).isEqualTo(3); + + syncTracker.markAllSyncsCompleted(); + assertThat(futures.size()).isEqualTo(0); + } + + @Test public void testCompleteOnTimeoutSyncFuture() throws InterruptedException, ExecutionException, TimeoutException { SyncTracker syncTracker = new SyncTracker(); diff --git a/tests/src/com/android/providers/media/photopicker/sync/SyncWorkerTestUtils.java b/tests/src/com/android/providers/media/photopicker/sync/SyncWorkerTestUtils.java index 52702ac7f..30f139c76 100644 --- a/tests/src/com/android/providers/media/photopicker/sync/SyncWorkerTestUtils.java +++ b/tests/src/com/android/providers/media/photopicker/sync/SyncWorkerTestUtils.java @@ -24,6 +24,7 @@ import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYN import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_ALBUM_ID; import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_AUTHORITY; import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_RESET_TYPE; +import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SEARCH_REQUEST_ID; import static com.android.providers.media.photopicker.sync.PickerSyncManager.SYNC_WORKER_INPUT_SYNC_SOURCE; import android.content.Context; @@ -31,6 +32,7 @@ import android.content.Intent; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.work.Configuration; import androidx.work.Data; import androidx.work.Worker; @@ -108,6 +110,39 @@ public class SyncWorkerTestUtils { SYNC_WORKER_INPUT_ALBUM_ID, albumId)); } + /** + * Returns input data for the SearchResultsSyncWorker to perform sync with the + * local provider. + */ + public static Data getLocalSearchResultsSyncInputData(int searchRequestId, + @NonNull String authority) { + return new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_ONLY, + SYNC_WORKER_INPUT_SEARCH_REQUEST_ID, searchRequestId, + SYNC_WORKER_INPUT_AUTHORITY, authority)); + } + + /** + * Returns input data for the SearchResultsSyncWorker to perform sync with the + * cloud provider. + */ + public static Data getCloudSearchResultsSyncInputData(int searchRequestId, + @NonNull String authority) { + return new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_CLOUD_ONLY, + SYNC_WORKER_INPUT_SEARCH_REQUEST_ID, searchRequestId, + SYNC_WORKER_INPUT_AUTHORITY, authority)); + } + + /** + * Returns input data for the SearchResultsSyncWorker to perform sync with the + * an invalid sync source + */ + public static Data getInvalidSearchResultsSyncInputData(int searchRequestId, + @Nullable String authority) { + return new Data(Map.of(SYNC_WORKER_INPUT_SYNC_SOURCE, SYNC_LOCAL_AND_CLOUD, + SYNC_WORKER_INPUT_SEARCH_REQUEST_ID, searchRequestId, + SYNC_WORKER_INPUT_AUTHORITY, authority)); + } + static <W extends Worker> W buildTestWorker(@NonNull Context context, @NonNull Class<W> workerClass) { return TestWorkerBuilder.from(context, workerClass) diff --git a/tests/src/com/android/providers/media/photopicker/util/CloudProviderUtilsTest.java b/tests/src/com/android/providers/media/photopicker/util/CloudProviderUtilsTest.java index 8649d4d56..bebbb557a 100644 --- a/tests/src/com/android/providers/media/photopicker/util/CloudProviderUtilsTest.java +++ b/tests/src/com/android/providers/media/photopicker/util/CloudProviderUtilsTest.java @@ -36,7 +36,6 @@ import java.util.Set; public class CloudProviderUtilsTest { - @Test public void getAllAvailableCloudProvidersTest() { final Context context = InstrumentationRegistry.getTargetContext(); diff --git a/tests/src/com/android/providers/media/photopicker/util/PickerDbTestUtils.java b/tests/src/com/android/providers/media/photopicker/util/PickerDbTestUtils.java index df29f9d40..1f6c0d428 100644 --- a/tests/src/com/android/providers/media/photopicker/util/PickerDbTestUtils.java +++ b/tests/src/com/android/providers/media/photopicker/util/PickerDbTestUtils.java @@ -16,6 +16,8 @@ package com.android.providers.media.photopicker.util; +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_ALBUM; + import static com.android.providers.media.util.MimeUtils.getExtensionFromMimeType; import static com.google.common.truth.Truth.assertWithMessage; @@ -62,6 +64,7 @@ public class PickerDbTestUtils { public static final int STANDARD_MIME_TYPE_EXTENSION = CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION_GIF; public static final String TEST_PACKAGE_NAME = "com.test.package"; + public static final String TEST_DIFFERENT_PACKAGE_NAME = "com.test.different.package"; public static final String LOCAL_PROVIDER = "com.local.provider"; public static final String CLOUD_PROVIDER = "com.cloud.provider"; @@ -312,6 +315,51 @@ public class PickerDbTestUtils { SIZE_BYTES, mimeType, STANDARD_MIME_TYPE_EXTENSION, isFavorite); } + public static Cursor getSuggestionCursor(String mediaSetId) { + String[] projectionKey = new String[]{ + CloudMediaProviderContract.SearchSuggestionColumns.MEDIA_SET_ID, + CloudMediaProviderContract.SearchSuggestionColumns.DISPLAY_TEXT, + CloudMediaProviderContract.SearchSuggestionColumns.TYPE, + CloudMediaProviderContract.SearchSuggestionColumns.MEDIA_COVER_ID, + }; + + String[] projectionValue = new String[]{ + mediaSetId, + "display_text", + SEARCH_SUGGESTION_ALBUM, + CLOUD_ID_1, + }; + + MatrixCursor c = new MatrixCursor(projectionKey); + c.addRow(projectionValue); + return c; + } + + public static Cursor getMediaCategoriesCursor(String categoryId) { + String[] projectionKey = new String[]{ + CloudMediaProviderContract.MediaCategoryColumns.ID, + CloudMediaProviderContract.MediaCategoryColumns.DISPLAY_NAME, + CloudMediaProviderContract.MediaCategoryColumns.MEDIA_CATEGORY_TYPE, + CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID1, + CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID2, + CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID3, + CloudMediaProviderContract.MediaCategoryColumns.MEDIA_COVER_ID4, + }; + + String[] projectionValue = new String[]{ + categoryId, + "display_text", + CloudMediaProviderContract.MEDIA_CATEGORY_TYPE_PEOPLE_AND_PETS, + CLOUD_ID_1, + CLOUD_ID_2, + /* MEDIA_COVER_ID3 */ null, + /* MEDIA_COVER_ID4 */ null + }; + + MatrixCursor c = new MatrixCursor(projectionKey); + c.addRow(projectionValue); + return c; + } public static String toMediaStoreUri(String localId) { if (localId == null) { return null; @@ -324,8 +372,8 @@ public class PickerDbTestUtils { } public static String getData(String authority, String displayName, String pickerSegmentType) { - return "/sdcard/.transforms/synthetic/" + pickerSegmentType + "/0/" + authority + "/media/" - + displayName; + return "/sdcard/.transforms/synthetic/" + pickerSegmentType + "/" + UserHandle.myUserId() + + "/" + authority + "/media/" + displayName; } public static void assertCloudAlbumCursor(Cursor cursor, String albumId, String displayName, diff --git a/tests/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2Test.java b/tests/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2Test.java index 852d5c7ae..83c5c1ada 100644 --- a/tests/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2Test.java +++ b/tests/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2Test.java @@ -16,6 +16,8 @@ package com.android.providers.media.photopicker.v2; +import static android.provider.MediaStore.PER_USER_RANGE; + import static com.android.providers.media.photopicker.util.PickerDbTestUtils.ALBUM_ID; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_1; @@ -24,8 +26,10 @@ import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLO import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_4; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_PROVIDER; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.DATE_TAKEN_MS; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.DURATION_MS; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.GENERATION_MODIFIED; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.GIF_IMAGE_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.HEIGHT; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.JPEG_IMAGE_MIME_TYPE; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_1; @@ -34,9 +38,12 @@ import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOC import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_4; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_PROVIDER; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.MP4_VIDEO_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.ORIENTATION; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.PNG_IMAGE_MIME_TYPE; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.STANDARD_MIME_TYPE_EXTENSION; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.TEST_DIFFERENT_PACKAGE_NAME; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.TEST_PACKAGE_NAME; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.WIDTH; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.assertAddAlbumMediaOperation; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.assertAddMediaOperation; import static com.android.providers.media.photopicker.util.PickerDbTestUtils.assertInsertGrantsOperation; @@ -49,53 +56,101 @@ import static com.android.providers.media.photopicker.util.PickerDbTestUtils.get import static com.android.providers.media.photopicker.v2.PickerDataLayerV2.COLUMN_GRANTS_COUNT; import static com.android.providers.media.photopicker.v2.model.AlbumsCursorWrapper.EMPTY_MEDIA_ID; +import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.MockitoAnnotations.initMocks; +import android.Manifest; +import android.compat.testing.PlatformCompatChangeRule; +import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ProviderInfo; import android.database.Cursor; +import android.database.MatrixCursor; import android.database.MergeCursor; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.CancellationSignal; import android.os.Process; import android.os.UserHandle; +import android.platform.test.annotations.DisableFlags; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; import android.provider.CloudMediaProviderContract; import android.provider.MediaStore; import android.test.mock.MockContentProvider; import android.test.mock.MockContentResolver; import androidx.test.InstrumentationRegistry; - +import androidx.test.filters.SdkSuppress; +import androidx.test.runner.AndroidJUnit4; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.Operation; +import androidx.work.WorkContinuation; +import androidx.work.WorkManager; + +import com.android.providers.media.MediaProvider; import com.android.providers.media.PickerUriResolver; +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.flags.Flags; +import com.android.providers.media.photopicker.CategoriesState; import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.SearchState; import com.android.providers.media.photopicker.data.ItemsProvider; import com.android.providers.media.photopicker.data.PickerDatabaseHelper; import com.android.providers.media.photopicker.data.PickerDbFacade; import com.android.providers.media.photopicker.data.model.UserId; import com.android.providers.media.photopicker.sync.PickerSyncLockManager; +import com.android.providers.media.photopicker.v2.model.MediaGroup; +import com.android.providers.media.photopicker.v2.model.MediaInMediaSetSyncRequestParams; +import com.android.providers.media.photopicker.v2.model.MediaSetsSyncRequestParams; import com.android.providers.media.photopicker.v2.model.MediaSource; +import com.android.providers.media.photopicker.v2.model.SearchSuggestion; +import com.android.providers.media.photopicker.v2.model.SearchTextRequest; +import com.android.providers.media.photopicker.v2.sqlite.MediaInMediaSetsDatabaseUtil; +import com.android.providers.media.photopicker.v2.sqlite.MediaSetsDatabaseUtil; +import com.android.providers.media.photopicker.v2.sqlite.PickerSQLConstants; +import com.android.providers.media.photopicker.v2.sqlite.SearchSuggestionsDatabaseUtils; +import com.android.providers.media.photopicker.v2.sqlite.SearchSuggestionsQuery; + +import com.google.common.util.concurrent.ListenableFuture; + +import libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges; import org.junit.After; import org.junit.Before; import org.junit.Ignore; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TestRule; +import org.junit.runner.RunWith; import org.mockito.Mock; import java.io.File; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +@RunWith(AndroidJUnit4.class) public class PickerDataLayerV2Test { @Mock private PickerSyncController mMockSyncController; @@ -103,12 +158,30 @@ public class PickerDataLayerV2Test { private Context mMockContext; @Mock private PackageManager mMockPackageManager; + @Mock + private SearchState mSearchState; + @Mock + private WorkManager mMockWorkManager; + @Mock + private Operation mMockOperation; + @Mock + private WorkContinuation mMockWorkContinuation; + @Mock + private ListenableFuture<Operation.State.SUCCESS> mMockFuture; + @Mock + CategoriesState mCategoriesState; private PickerDbFacade mFacade; private Context mContext; private MockContentResolver mMockContentResolver; private TestContentProvider mLocalProvider; private TestContentProvider mCloudProvider; + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + @Rule + public final TestRule mCompatChangeRule = new PlatformCompatChangeRule(); + private static class TestContentProvider extends MockContentProvider { private Cursor mQueryResult = null; @@ -148,9 +221,20 @@ public class PickerDataLayerV2Test { doReturn(LOCAL_PROVIDER).when(mMockSyncController).getLocalProvider(); doReturn(CLOUD_PROVIDER).when(mMockSyncController).getCloudProvider(); doReturn(CLOUD_PROVIDER).when(mMockSyncController).getCloudProviderOrDefault(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMediaSets(any(), any()); + doReturn(true).when(mMockSyncController).shouldQueryLocalMediaSets(any()); doReturn(mFacade).when(mMockSyncController).getDbFacade(); + doReturn(mSearchState).when(mMockSyncController).getSearchState(); + doReturn(mCategoriesState).when(mMockSyncController).getCategoriesState(); doReturn(new PickerSyncLockManager()).when(mMockSyncController).getPickerSyncLockManager(); doReturn(mMockContentResolver).when(mMockContext).getContentResolver(); + + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity( + Manifest.permission.LOG_COMPAT_CHANGE, + Manifest.permission.READ_COMPAT_CHANGE_CONFIG, + Manifest.permission.READ_DEVICE_CONFIG); } @After @@ -435,7 +519,12 @@ public class PickerDataLayerV2Test { /* writeCount */1); assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursorForMediaWithGrants, /* writeCount */1); - int testUid = 123; + // testUid should be selected such that the userId computed from this uid later in the code + // flow matches the current userId. UserId is computed using + // PickerSyncController#uidToUser() where the userId = uid / PER_USER_RANGE. + // So testUid is = + // (a random number smaller than PER_USER_RANGE) + (PER_USER_RANGE * UserHandle.myUserId()) + int testUid = 11 + (PER_USER_RANGE * UserHandle.myUserId()); doReturn(mMockPackageManager) .when(mMockContext).getPackageManager(); String[] packageNames = new String[]{TEST_PACKAGE_NAME}; @@ -470,6 +559,188 @@ public class PickerDataLayerV2Test { } @Test + @EnableFlags(Flags.FLAG_REVOKE_ACCESS_OWNED_PHOTOS) + @EnableCompatChanges({MediaProvider.ENABLE_OWNED_PHOTOS}) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.BAKLAVA) + public void testPreGrantsForOwnedPhotos() { + assumeTrue(MediaProvider.isOwnedPhotosEnabled(Process.myUid())); + + doReturn(mMockPackageManager) + .when(mMockContext).getPackageManager(); + String[] packageNames = new String[]{TEST_PACKAGE_NAME}; + doReturn(packageNames).when(mMockPackageManager).getPackagesForUid(Process.myUid()); + Map<String, Integer> idVsExpectedPreGrantedValue = populateMediaAndMediaGrantsTable(); + int totalCount = idVsExpectedPreGrantedValue.size(); + + try (Cursor cr = PickerDataLayerV2.queryMedia( + mMockContext, getMediaQueryExtras(Long.MAX_VALUE, Long.MAX_VALUE, /* pageSize */ 10, + new ArrayList<>(List.of(LOCAL_PROVIDER)), + new ArrayList<>(List.of("image/*")), + MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP, + /*callingUid*/ Process.myUid()))) { + + assertWithMessage( + "Unexpected number of rows in media query result") + .that(cr.getCount()).isEqualTo(totalCount); + + cr.moveToFirst(); + for (int i = 0; i < totalCount; i++) { + int id = cr.getInt(cr.getColumnIndex("id")); + int isPreGranted = cr.getInt(cr.getColumnIndex("is_pre_granted")); + assertEquals(idVsExpectedPreGrantedValue.get(String.valueOf(id)).intValue(), + isPreGranted); + cr.moveToNext(); + } + } + } + + @Test + @EnableFlags(Flags.FLAG_REVOKE_ACCESS_OWNED_PHOTOS) + @EnableCompatChanges({MediaProvider.ENABLE_OWNED_PHOTOS}) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.BAKLAVA) + public void testPreGrantedCountForOwnedPhotos() { + assumeTrue(MediaProvider.isOwnedPhotosEnabled(Process.myUid())); + + doReturn(mMockPackageManager) + .when(mMockContext).getPackageManager(); + String[] packageNames = new String[]{TEST_PACKAGE_NAME}; + doReturn(packageNames).when(mMockPackageManager).getPackagesForUid(0); + Map<String, Integer> idVsPreGranted = populateMediaAndMediaGrantsTable(); + int totalPreGranted = (int) idVsPreGranted.values().stream() + .filter(preGranted -> Integer.valueOf(1).equals(preGranted)) + .count(); + + Bundle queryArgs = new Bundle(); + queryArgs.putInt(Intent.EXTRA_UID, 0); + try (Cursor cr = PickerDataLayerV2.fetchCountForPreGrantedItems(mMockContext, queryArgs)) { + cr.moveToFirst(); + assertEquals(totalPreGranted, cr.getInt(cr.getColumnIndex(COLUMN_GRANTS_COUNT))); + } + } + + @Test + @EnableFlags(Flags.FLAG_REVOKE_ACCESS_OWNED_PHOTOS) + @EnableCompatChanges({MediaProvider.ENABLE_OWNED_PHOTOS}) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.BAKLAVA) + public void testPreviewForOwnedPhotos() { + assumeTrue(MediaProvider.isOwnedPhotosEnabled(Process.myUid())); + + doReturn(mMockPackageManager) + .when(mMockContext).getPackageManager(); + String[] packageNames = new String[]{TEST_PACKAGE_NAME}; + doReturn(packageNames).when(mMockPackageManager).getPackagesForUid(0); + + Map<String, Integer> idVsPreGranted = populateMediaAndMediaGrantsTable(); + int totalPreGranted = (int) idVsPreGranted.values().stream() + .filter(preGranted -> Integer.valueOf(1).equals(preGranted)) + .count(); + + Bundle queryArgs = getMediaQueryExtras(Long.MAX_VALUE, Long.MAX_VALUE, /* pageSize */ 10, + new ArrayList<>(List.of(LOCAL_PROVIDER)), + new ArrayList<>(List.of("image/*")), + MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP, + 0); + queryArgs.putBoolean("is_preview_session", true); + + try (Cursor cr = PickerDataLayerV2.queryPreviewMedia(mMockContext, queryArgs)) { + assertEquals(totalPreGranted, cr.getCount()); + cr.moveToFirst(); + for (int i = 0; i < totalPreGranted; i++) { + int id = cr.getInt(cr.getColumnIndex("id")); + int preGranted = cr.getInt(cr.getColumnIndex("is_pre_granted")); + assertEquals(1, (int) idVsPreGranted.get(String.valueOf(id))); + assertEquals(1, preGranted); + cr.moveToNext(); + } + } + } + + private Map<String, Integer> populateMediaAndMediaGrantsTable() { + + Map<String, Integer> idVsExpectedPreGrantedValue = new HashMap<>(); + + // 1. ownerPackageName != TEST_PACKAGE_NAME and no media grants. + // preGranted should be false + Cursor cursorWithDifferentOwnerPackageName = getMediaCursorWithOwnerPackageNameAndUserId( + "101", TEST_DIFFERENT_PACKAGE_NAME); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursorWithDifferentOwnerPackageName, + /*writeCount*/ 1); + idVsExpectedPreGrantedValue.put("101", 0); + + // 2. ownerPackageName == TEST_PACKAGE_NAME and no media grants. + // preGranted should be true + Cursor cursorWithCorrectOwnerPackageName = getMediaCursorWithOwnerPackageNameAndUserId( + "102", TEST_PACKAGE_NAME); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursorWithCorrectOwnerPackageName, + /*writeCount*/ 1); + idVsExpectedPreGrantedValue.put("102", 1); + + // 3. ownerPackageName != TEST_PACKAGE_NAME + // and media_grants with packageName == TEST_PACKAGE_NAME. + // preGranted should be true + Cursor cursorWithCorrectMediaGrants = getMediaCursorWithOwnerPackageNameAndUserId( + "103", TEST_DIFFERENT_PACKAGE_NAME); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursorWithCorrectMediaGrants, + /*writeCount*/ 1); + assertInsertGrantsOperation(mFacade, getMediaGrantsCursor("103", TEST_PACKAGE_NAME, + 0), /*writeCount*/ 1); + idVsExpectedPreGrantedValue.put("103", 1); + + // 4. ownerPackageName != TEST_PACKAGE_NAME + // and media_grants with packageName != TEST_PACKAGE_NAME. + // preGranted should be false + Cursor cursorWithDifferentMediaGrants = getMediaCursorWithOwnerPackageNameAndUserId( + "104", TEST_DIFFERENT_PACKAGE_NAME); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursorWithDifferentMediaGrants, + /*writeCount*/ 1); + assertInsertGrantsOperation(mFacade, getMediaGrantsCursor("104", + TEST_DIFFERENT_PACKAGE_NAME, 0), /*writeCount*/ 1); + idVsExpectedPreGrantedValue.put("104", 0); + + return idVsExpectedPreGrantedValue; + } + + private Cursor getMediaCursorWithOwnerPackageNameAndUserId(String id, String ownerPackageName) { + String[] projectionKey = new String[]{ + CloudMediaProviderContract.MediaColumns.ID, + CloudMediaProviderContract.MediaColumns.MEDIA_STORE_URI, + CloudMediaProviderContract.MediaColumns.DATE_TAKEN_MILLIS, + CloudMediaProviderContract.MediaColumns.SYNC_GENERATION, + CloudMediaProviderContract.MediaColumns.SIZE_BYTES, + CloudMediaProviderContract.MediaColumns.MIME_TYPE, + CloudMediaProviderContract.MediaColumns.STANDARD_MIME_TYPE_EXTENSION, + CloudMediaProviderContract.MediaColumns.DURATION_MILLIS, + CloudMediaProviderContract.MediaColumns.IS_FAVORITE, + CloudMediaProviderContract.MediaColumns.HEIGHT, + CloudMediaProviderContract.MediaColumns.WIDTH, + CloudMediaProviderContract.MediaColumns.ORIENTATION, + CloudMediaProviderContract.MediaColumns.OWNER_PACKAGE_NAME, + CloudMediaProviderContract.MediaColumns.USER_ID + }; + + String[] projectionValue = new String[]{ + id, + null, + String.valueOf(DATE_TAKEN_MS), + String.valueOf(GENERATION_MODIFIED), + String.valueOf(1), + JPEG_IMAGE_MIME_TYPE, + String.valueOf(STANDARD_MIME_TYPE_EXTENSION), + String.valueOf(DURATION_MS), + String.valueOf(0), + String.valueOf(HEIGHT), + String.valueOf(WIDTH), + String.valueOf(ORIENTATION), + ownerPackageName, + String.valueOf(0) + }; + + MatrixCursor c = new MatrixCursor(projectionKey); + c.addRow(projectionValue); + return c; + } + + @Test public void testQueryLocalMediaForPreview() { Cursor cursorForMediaWithoutGrants = getMediaCursor(LOCAL_ID_1, DATE_TAKEN_MS + 1, GENERATION_MODIFIED, /* mediaStoreUri */ null, /* sizeBytes */ 1, @@ -490,7 +761,12 @@ public class PickerDataLayerV2Test { assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursorForMediaWithGrantsButDeSelected, /* writeCount */1); - int testUid = 123; + // testUid should be selected such that the userId computed from this uid later in the code + // flow matches the current userId. UserId is computed using + // PickerSyncController#uidToUser() where the userId = uid / PER_USER_RANGE. + // So testUid is = + // (a random number smaller than PER_USER_RANGE) + (PER_USER_RANGE * UserHandle.myUserId()) + int testUid = 11 + (PER_USER_RANGE * UserHandle.myUserId()); doReturn(mMockPackageManager) .when(mMockContext).getPackageManager(); String[] packageNames = new String[]{TEST_PACKAGE_NAME}; @@ -539,6 +815,77 @@ public class PickerDataLayerV2Test { } @Test + public void testQueryMediaSets() { + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add("image/*"); + String mediaSetId1 = "mediaSetId1"; + String mediaSetId2 = "mediaSetId2"; + String displayName1 = "displayName1"; + String displayName2 = "displayName2"; + String coverId1 = "56"; + String coverId2 = "76"; + String categoryId = "id"; + + String[] columns = new String[]{ + CloudMediaProviderContract.MediaSetColumns.ID, + CloudMediaProviderContract.MediaSetColumns.DISPLAY_NAME, + CloudMediaProviderContract.MediaSetColumns.MEDIA_COVER_ID + }; + + // Prep the media sets table + MatrixCursor cursor = new MatrixCursor(columns); + cursor.addRow(new Object[] { mediaSetId1, displayName1, coverId1 }); + cursor.addRow(new Object[] { mediaSetId2, displayName2, coverId2 }); + + MediaSetsDatabaseUtil.cacheMediaSets( + mFacade.getDatabase(), cursor, categoryId, + SearchProvider.AUTHORITY, mimeTypes); + + Bundle extras = new Bundle(); + extras.putString( + MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, + SearchProvider.AUTHORITY); + extras.putStringArrayList( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<>(List.of("image/*"))); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArrayList("providers", new ArrayList<>(List.of(SearchProvider.AUTHORITY))); + + try (Cursor mediaSets = PickerDataLayerV2.queryMediaSets(extras)) { + assertNotNull(mediaSets); + assertEquals(2, mediaSets.getCount()); + + if (mediaSets.moveToFirst()) { + String retrievedMediaSetId1 = mediaSets.getString(mediaSets.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns.GROUP_ID.getColumnName())); + assertEquals(mediaSetId1, retrievedMediaSetId1); + String retrievedDisplayName1 = mediaSets.getString(mediaSets.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns.DISPLAY_NAME.getColumnName())); + assertEquals(retrievedDisplayName1, displayName1); + String retrievedUri1 = mediaSets.getString(mediaSets.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns.UNWRAPPED_COVER_URI + .getColumnName() + )); + assertTrue(retrievedUri1.contains(coverId1)); + + mediaSets.moveToNext(); + String retrievedMediaSetId2 = mediaSets.getString(mediaSets.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns.GROUP_ID.getColumnName())); + assertEquals(mediaSetId2, retrievedMediaSetId2); + String retrievedDisplayName2 = mediaSets.getString(mediaSets.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns.DISPLAY_NAME.getColumnName())); + assertEquals(retrievedDisplayName2, displayName2); + String retrievedUri2 = mediaSets.getString(mediaSets.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns.UNWRAPPED_COVER_URI + .getColumnName() + )); + assertTrue(retrievedUri2.contains(coverId2)); + + } + } + } + + @Test public void queryMediaOnlyLocalWithPreSelection() { Cursor cursorLocal1 = getMediaCursor(LOCAL_ID_1, DATE_TAKEN_MS, GENERATION_MODIFIED, /* mediaStoreUri */ null, /* sizeBytes */ 1, MP4_VIDEO_MIME_TYPE, @@ -683,6 +1030,76 @@ public class PickerDataLayerV2Test { } @Test + public void testQueryMediaInMediaSet() { + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor1, 1); + final Cursor cursor2 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + Long mediaSetPickerId = 1L; + + int cloudRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mFacade.getDatabase(), List.of( + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId) + ), CLOUD_PROVIDER + ); + assertEquals( + "Number of rows inserted should be equal to the number of items in the cursor,", + /*expected*/cloudRowsInserted, + /*actual*/1); + + int localRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mFacade.getDatabase(), List.of( + getContentValues(LOCAL_ID_1, null, mediaSetPickerId) + ), LOCAL_PROVIDER + ); + assertEquals( + "Number of rows inserted is incorrect", + localRowsInserted, + 1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + extras.putLong( + MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_PICKER_ID, + mediaSetPickerId); + extras.putString( + MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_AUTHORITY, + LOCAL_PROVIDER); + + try (Cursor cursor = + PickerDataLayerV2.queryMediaInMediaSet(extras)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(2); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_2); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_1); + } + } + + + @Test + @DisableFlags(Flags.FLAG_REVOKE_ACCESS_OWNED_PHOTOS) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) public void testFetchMediaGrantsCount() { int testUid = 123; int userId = PickerSyncController.uidToUserId(testUid); @@ -718,7 +1135,7 @@ public class PickerDataLayerV2Test { Bundle input = new Bundle(); input.putInt(Intent.EXTRA_UID, testUid); - try (Cursor cr = PickerDataLayerV2.fetchMediaGrantsCount( + try (Cursor cr = PickerDataLayerV2.fetchCountForPreGrantedItems( mMockContext, input)) { // cursor should only contain 1 row that represents the count. @@ -2077,6 +2494,379 @@ public class PickerDataLayerV2Test { } } + @Test + public void testQuerySearchSuggestionsZeroState() { + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getCloudProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController) + .getCloudProviderOrDefault(any()); + doReturn(mSearchState).when(mMockSyncController).getSearchState(); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + doReturn(true).when(mSearchState).isCloudSearchEnabled(any()); + + final Bundle bundle = new Bundle(); + bundle.putString("prefix", ""); + bundle.putStringArrayList("providers", new ArrayList<>(List.of(SearchProvider.AUTHORITY))); + final SearchSuggestionsQuery query = new SearchSuggestionsQuery(bundle); + + // Async tasks are run synchronously during tests to make tests deterministic and prevent + // flaky test results. + final Executor currentThreadExecutor = Runnable::run; + + try (Cursor cursor = PickerDataLayerV2.querySearchSuggestions( + mContext, bundle, currentThreadExecutor, null)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(SearchProvider.DEFAULT_SUGGESTION_RESULTS.getCount()); + + final String projection = PickerSQLConstants.SearchSuggestionsResponseColumns + .MEDIA_SET_ID.getProjection(); + if (cursor.moveToFirst() && SearchProvider.DEFAULT_SUGGESTION_RESULTS.moveToFirst()) { + do { + assertWithMessage("Media ID is not as expected") + .that(cursor.getString(cursor.getColumnIndexOrThrow(projection))) + .isEqualTo(SearchProvider.DEFAULT_SUGGESTION_RESULTS.getString( + SearchProvider.DEFAULT_SUGGESTION_RESULTS + .getColumnIndexOrThrow(projection))); + } while (cursor.moveToNext() + && SearchProvider.DEFAULT_SUGGESTION_RESULTS.moveToNext()); + } + } + + final List<SearchSuggestion> searchSuggestions = SearchSuggestionsDatabaseUtils + .getCachedSuggestions(mFacade.getDatabase(), query); + + assertWithMessage("Suggestions should not be null") + .that(searchSuggestions) + .isNotNull(); + + assertWithMessage("Suggestions size is not as expected") + .that(searchSuggestions.size()) + .isEqualTo(SearchProvider.DEFAULT_SUGGESTION_RESULTS.getCount()); + } + + @Test + public void testQuerySearchSuggestionsNonZeroState() { + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getCloudProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController) + .getCloudProviderOrDefault(any()); + doReturn(mSearchState).when(mMockSyncController).getSearchState(); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + doReturn(true).when(mSearchState).isCloudSearchEnabled(any()); + + final Bundle bundle = new Bundle(); + bundle.putString("prefix", "x"); + bundle.putStringArrayList("providers", new ArrayList<>(List.of(SearchProvider.AUTHORITY))); + final SearchSuggestionsQuery query = new SearchSuggestionsQuery(bundle); + + // Async tasks are run synchronously during tests to make tests deterministic and prevent + // flaky test results. + final Executor currentThreadExecutor = Runnable::run; + + try (Cursor cursor = PickerDataLayerV2.querySearchSuggestions( + mContext, bundle, currentThreadExecutor, null)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(SearchProvider.DEFAULT_SUGGESTION_RESULTS.getCount()); + + final String projection = PickerSQLConstants.SearchSuggestionsResponseColumns + .MEDIA_SET_ID.getProjection(); + if (cursor.moveToFirst() && SearchProvider.DEFAULT_SUGGESTION_RESULTS.moveToFirst()) { + do { + assertWithMessage("Media ID is not as expected") + .that(cursor.getString(cursor.getColumnIndexOrThrow(projection))) + .isEqualTo(SearchProvider.DEFAULT_SUGGESTION_RESULTS.getString( + SearchProvider.DEFAULT_SUGGESTION_RESULTS + .getColumnIndexOrThrow(projection))); + } while (cursor.moveToNext() + && SearchProvider.DEFAULT_SUGGESTION_RESULTS.moveToNext()); + } + } + + final List<SearchSuggestion> searchSuggestions = SearchSuggestionsDatabaseUtils + .getCachedSuggestions(mFacade.getDatabase(), query); + + assertWithMessage("Suggestions should not be null") + .that(searchSuggestions) + .isNotNull(); + + assertWithMessage("Suggestions size is not as expected") + .that(searchSuggestions.size()) + .isEqualTo(0); + } + + @Test + public void testQuerySearchSuggestionsWithHistory() { + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getCloudProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController) + .getCloudProviderOrDefault(any()); + doReturn(mSearchState).when(mMockSyncController).getSearchState(); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + doReturn(true).when(mSearchState).isCloudSearchEnabled(any()); + + final Bundle bundle = new Bundle(); + bundle.putString("prefix", ""); + bundle.putStringArrayList("providers", new ArrayList<>(List.of(SearchProvider.AUTHORITY))); + final SearchSuggestionsQuery query = new SearchSuggestionsQuery(bundle); + + // Async tasks are run synchronously during tests to make tests deterministic and prevent + // flaky test results. + final Executor currentThreadExecutor = Runnable::run; + + SearchSuggestionsDatabaseUtils.saveSearchHistory( + mFacade.getDatabase(), + new SearchTextRequest(null, "mountains")); + + try (Cursor cursor = PickerDataLayerV2.querySearchSuggestions( + mContext, bundle, currentThreadExecutor, null)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(SearchProvider.DEFAULT_SUGGESTION_RESULTS.getCount() + 1); + + final String projection = PickerSQLConstants.SearchSuggestionsResponseColumns + .MEDIA_SET_ID.getProjection(); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected") + .that(cursor.getString(cursor.getColumnIndexOrThrow(projection))) + .isNull(); + + if (cursor.moveToNext() && SearchProvider.DEFAULT_SUGGESTION_RESULTS.moveToFirst()) { + do { + assertWithMessage("Media ID is not as expected") + .that(cursor.getString(cursor.getColumnIndexOrThrow(projection))) + .isEqualTo(SearchProvider.DEFAULT_SUGGESTION_RESULTS.getString( + SearchProvider.DEFAULT_SUGGESTION_RESULTS + .getColumnIndexOrThrow(projection))); + } while (cursor.moveToNext() + && SearchProvider.DEFAULT_SUGGESTION_RESULTS.moveToNext()); + } + } + } + + @Test + public void testHandleNewSearchRequest() { + doReturn(true).when(mMockSyncController).shouldQueryLocalMediaForSearch(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMediaForSearch(any(), any()); + doReturn(mMockOperation).when(mMockWorkManager) + .enqueueUniqueWork(anyString(), any(ExistingWorkPolicy.class), + any(OneTimeWorkRequest.class)); + doReturn(mMockFuture).when(mMockOperation).getResult(); + + final String searchText = "volcano"; + final Bundle extras = getCreateSearchRequestExtras(new SearchTextRequest(null, searchText)); + final Executor currentThreadExecutor = Runnable::run; + + final Bundle result = PickerDataLayerV2.handleNewSearchRequest( + mMockContext, extras, currentThreadExecutor, mMockWorkManager); + + // Assert that a new search request was created + assertThat(result).isNotNull(); + assertThat(result.getInt("search_request_id")).isEqualTo(1); + + // Assert that local sync, cloud sync and cache clearing work was scheduled + verify(mMockWorkManager, times(3)) + .enqueueUniqueWork(anyString(), any(ExistingWorkPolicy.class), + any(OneTimeWorkRequest.class)); + + // Assert that search request was saved as search history in database + final List<SearchSuggestion> suggestions = + SearchSuggestionsDatabaseUtils.getHistorySuggestions( + mFacade.getDatabase(), + new SearchSuggestionsQuery("", new ArrayList<>())); + assertThat(suggestions.size()).isEqualTo(1); + assertThat(suggestions.get(0).getSearchText()).isEqualTo(searchText); + } + + @Test + public void testTriggerMediaSetsSyncRequest() { + doReturn(true).when(mMockSyncController).shouldQueryLocalMediaSets(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMediaSets(any(), any()); + doReturn(mMockWorkContinuation) + .when(mMockWorkManager) + .beginUniqueWork( + anyString(), any(ExistingWorkPolicy.class), any(List.class)); + // Handle .then chaining + doReturn(mMockWorkContinuation) + .when(mMockWorkContinuation) + .then(any(List.class)); + doReturn(mMockOperation).when(mMockWorkContinuation).enqueue(); + doReturn(mMockFuture).when(mMockOperation).getResult(); + + Bundle extras = new Bundle(); + extras.putString( + MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, + SearchProvider.AUTHORITY); + extras.putStringArray( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new String[] { "image/*" }); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, "id"); + extras.putStringArrayList("providers", new ArrayList<>(List.of(SearchProvider.AUTHORITY))); + + PickerDataLayerV2.triggerMediaSetsSync(extras, mContext, mMockWorkManager); + + // Assert that both local and cloud syncs were scheduled + verify(mMockWorkManager, times(1)).beginUniqueWork( + anyString(), any(ExistingWorkPolicy.class), any(List.class)); + verify(mMockWorkContinuation, times(1)).then(any(List.class)); + verify(mMockWorkContinuation, times(1)).enqueue(); + } + @Test + public void testTriggerMediaInMediaSetSyncRequest() { + doReturn(true).when(mMockSyncController).shouldQueryLocalMediaSets(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMediaSets(any(), any()); + doReturn(mMockOperation).when(mMockWorkManager) + .enqueueUniqueWork(anyString(), any(ExistingWorkPolicy.class), + any(OneTimeWorkRequest.class)); + doReturn(mMockFuture).when(mMockOperation).getResult(); + + Bundle extras = new Bundle(); + extras.putString( + MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_AUTHORITY, + SearchProvider.AUTHORITY); + extras.putLong(MediaInMediaSetSyncRequestParams.KEY_PARENT_MEDIA_SET_PICKER_ID, 1); + extras.putStringArrayList("providers", new ArrayList<>(List.of(SearchProvider.AUTHORITY))); + + PickerDataLayerV2.triggerMediaSyncForMediaSet(extras, mContext, mMockWorkManager); + + // Assert that both local and cloud syncs were scheduled + verify(mMockWorkManager, times(1)) + .enqueueUniqueWork(anyString(), any(ExistingWorkPolicy.class), + any(OneTimeWorkRequest.class)); + } + + @Test + public void testQueryCategoriesAndAlbums() { + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getCloudProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController) + .getCloudProviderOrDefault(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + doReturn(true).when(mCategoriesState).areCategoriesEnabled(any(), any()); + + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_1, LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + + try (Cursor cursor = PickerDataLayerV2.queryCategoriesAndAlbums( + mContext, + getMediaQueryExtras(Long.MAX_VALUE, Long.MAX_VALUE, 100, + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, SearchProvider.AUTHORITY))), + /* cancellationSignal */ null)) { + assertWithMessage("Count of albums and categories") + .that(cursor.getCount()) + .isEqualTo(5); + + cursor.moveToFirst(); + assertWithMessage("Unexpected media group") + .that(MediaGroup.valueOf( + cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .MEDIA_GROUP.getColumnName())))) + .isEqualTo(MediaGroup.ALBUM); + assertWithMessage("Unexpected album id") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns.GROUP_ID.getColumnName()))) + .isEqualTo(CloudMediaProviderContract.AlbumColumns.ALBUM_ID_FAVORITES); + assertWithMessage("Unexpected picker id") + .that(cursor.getLong(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .PICKER_ID.getColumnName()))) + .isEqualTo(0L); + + cursor.moveToNext(); + assertWithMessage("Unexpected media group") + .that(MediaGroup.valueOf( + cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .MEDIA_GROUP.getColumnName())))) + .isEqualTo(MediaGroup.ALBUM); + assertWithMessage("Unexpected album id") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns.GROUP_ID.getColumnName()))) + .isEqualTo(CloudMediaProviderContract.AlbumColumns.ALBUM_ID_CAMERA); + assertWithMessage("Unexpected picker id") + .that(cursor.getLong(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .PICKER_ID.getColumnName()))) + .isEqualTo(1L); + + cursor.moveToNext(); + // Assert that the next media group is people and pets category + assertWithMessage("Unexpected media group") + .that(MediaGroup.valueOf( + cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .MEDIA_GROUP.getColumnName())))) + .isEqualTo(MediaGroup.CATEGORY); + assertWithMessage("Unexpected picker id") + .that(cursor.getLong(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .PICKER_ID.getColumnName()))) + .isEqualTo(2L); + + cursor.moveToNext(); + assertWithMessage("Unexpected media group") + .that(MediaGroup.valueOf( + cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .MEDIA_GROUP.getColumnName())))) + .isEqualTo(MediaGroup.ALBUM); + assertWithMessage("Unexpected album id") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns.GROUP_ID.getColumnName()))) + .isEqualTo(CloudMediaProviderContract.AlbumColumns.ALBUM_ID_VIDEOS); + assertWithMessage("Unexpected picker id") + .that(cursor.getLong(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .PICKER_ID.getColumnName()))) + .isEqualTo(3L); + + cursor.moveToNext(); + // Assert that the next media group is a cloud album + assertWithMessage("Unexpected media group") + .that(MediaGroup.valueOf( + cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .MEDIA_GROUP.getColumnName())))) + .isEqualTo(MediaGroup.ALBUM); + final Uri coverUri = Uri.parse( + cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .UNWRAPPED_COVER_URI.getColumnName()))); + assertWithMessage("Unexpected media group") + .that(coverUri.getLastPathSegment()) + .isEqualTo(LOCAL_ID_1); + assertWithMessage("Unexpected picker id") + .that(cursor.getLong(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaGroupResponseColumns + .PICKER_ID.getColumnName()))) + .isEqualTo(4L); + } + } + + private static Bundle getCreateSearchRequestExtras(SearchTextRequest searchTextRequest) { + final Bundle bundle = new Bundle(); + bundle.putString("search_text", searchTextRequest.getSearchText()); + bundle.putStringArrayList("providers", new ArrayList<>(List.of(SearchProvider.AUTHORITY))); + return bundle; + } + private static void assertMediaCursor(Cursor cursor, String id, String authority, Long dateTaken, String mimeType) { assertMediaCursor(cursor, id, authority, dateTaken, mimeType, @@ -2227,4 +3017,18 @@ public class PickerDataLayerV2Test { extras.putString("album_authority", albumAuthority); return extras; } + + private ContentValues getContentValues( + String localId, String cloudId, Long mediaSetPickerId) { + ContentValues contentValues = new ContentValues(); + contentValues.put( + PickerSQLConstants.MediaInMediaSetsTableColumns.CLOUD_ID.getColumnName(), cloudId); + contentValues.put( + PickerSQLConstants.MediaInMediaSetsTableColumns.LOCAL_ID.getColumnName(), localId); + contentValues.put( + PickerSQLConstants.MediaInMediaSetsTableColumns.MEDIA_SETS_PICKER_ID + .getColumnName(), + mediaSetPickerId); + return contentValues; + } } diff --git a/tests/src/com/android/providers/media/photopicker/v2/PickerUriResolverV2Test.java b/tests/src/com/android/providers/media/photopicker/v2/PickerUriResolverV2Test.java index 8b89795de..4c03b59e7 100644 --- a/tests/src/com/android/providers/media/photopicker/v2/PickerUriResolverV2Test.java +++ b/tests/src/com/android/providers/media/photopicker/v2/PickerUriResolverV2Test.java @@ -25,33 +25,46 @@ import org.junit.Test; public class PickerUriResolverV2Test { @Test public void testMediaQuery() { - assertEquals(PickerUriResolverV2.sUriMatcher.match( - Uri.parse("content://media/picker_internal/v2/media")), - PickerUriResolverV2.PICKER_INTERNAL_MEDIA + assertEquals( + PickerUriResolverV2.PICKER_INTERNAL_MEDIA, + PickerUriResolverV2.sUriMatcher.match( + Uri.parse("content://media/picker_internal/v2/media")) ); } @Test public void testAlbumQuery() { - assertEquals(PickerUriResolverV2.sUriMatcher.match( - Uri.parse("content://media/picker_internal/v2/album")), - PickerUriResolverV2.PICKER_INTERNAL_ALBUM + assertEquals( + PickerUriResolverV2.PICKER_INTERNAL_ALBUM, + PickerUriResolverV2.sUriMatcher.match( + Uri.parse("content://media/picker_internal/v2/album")) ); } @Test public void testAlbumContentQuery() { - assertEquals(PickerUriResolverV2.sUriMatcher.match( - Uri.parse("content://media/picker_internal/v2/album/album_id")), - PickerUriResolverV2.PICKER_INTERNAL_ALBUM_CONTENT + assertEquals( + PickerUriResolverV2.PICKER_INTERNAL_ALBUM_CONTENT, + PickerUriResolverV2.sUriMatcher.match( + Uri.parse("content://media/picker_internal/v2/album/album_id")) ); } @Test public void testAvailableProvidersQuery() { - assertEquals(PickerUriResolverV2.sUriMatcher.match( - Uri.parse("content://media/picker_internal/v2/available_providers")), - PickerUriResolverV2.PICKER_INTERNAL_AVAILABLE_PROVIDERS + assertEquals( + PickerUriResolverV2.PICKER_INTERNAL_AVAILABLE_PROVIDERS, + PickerUriResolverV2.sUriMatcher.match( + Uri.parse("content://media/picker_internal/v2/available_providers")) + ); + } + + @Test + public void testSearchResultsQuery() { + assertEquals( + PickerUriResolverV2.PICKER_INTERNAL_SEARCH_MEDIA, + PickerUriResolverV2.sUriMatcher.match( + Uri.parse("content://media/picker_internal/v2/search_media/132")) ); } } diff --git a/tests/src/com/android/providers/media/photopicker/v2/SearchSuggestionsProviderTest.java b/tests/src/com/android/providers/media/photopicker/v2/SearchSuggestionsProviderTest.java new file mode 100644 index 000000000..adc000bd1 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/v2/SearchSuggestionsProviderTest.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.v2; + +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_ALBUM; +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_HISTORY; +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_FACE; + +import static com.android.providers.media.photopicker.PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.platform.test.annotations.EnableFlags; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.flags.Flags; +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.SearchState; +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; +import com.android.providers.media.photopicker.v2.model.SearchSuggestion; +import com.android.providers.media.photopicker.v2.sqlite.PickerSQLConstants; +import com.android.providers.media.photopicker.v2.sqlite.SearchSuggestionsDatabaseUtils; +import com.android.providers.media.photopicker.v2.sqlite.SearchSuggestionsQuery; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +@EnableFlags(Flags.FLAG_CLOUD_MEDIA_PROVIDER_SEARCH) +public class SearchSuggestionsProviderTest { + @Mock + private PickerSyncController mMockSyncController; + @Mock + private SearchState mSearchState; + private Context mContext; + private SQLiteDatabase mDatabase; + private PickerDbFacade mFacade; + + @Before + public void setup() { + initMocks(this); + + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + PickerSyncController.setInstance(mMockSyncController); + + final File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + final PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + mDatabase = helper.getWritableDatabase(); + mFacade = new PickerDbFacade( + mContext, new PickerSyncLockManager(), LOCAL_PICKER_PROVIDER_AUTHORITY); + mFacade.setCloudProvider(SearchProvider.AUTHORITY); + + doReturn(LOCAL_PICKER_PROVIDER_AUTHORITY).when(mMockSyncController).getLocalProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getCloudProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController) + .getCloudProviderOrDefault(any()); + doReturn(mFacade).when(mMockSyncController).getDbFacade(); + doReturn(mSearchState).when(mMockSyncController).getSearchState(); + doReturn(new PickerSyncLockManager()).when(mMockSyncController).getPickerSyncLockManager(); + } + + @Test + public void testCacheSearchSuggestionsNonZeroState() { + final SearchSuggestionsQuery query = new SearchSuggestionsQuery(getQueryArgs("x")); + final SearchSuggestion suggestion = new SearchSuggestion( + "search-text", + "media-set-id", + "authority", + SEARCH_SUGGESTION_ALBUM, + null); + + final boolean result = SearchSuggestionsProvider + .maybeCacheSearchSuggestions(query, List.of(suggestion)); + + assertThat(result).isFalse(); + } + + @Test + public void testCacheEmptySearchSuggestions() { + final SearchSuggestionsQuery query = new SearchSuggestionsQuery(getQueryArgs("")); + + final boolean result = SearchSuggestionsProvider + .maybeCacheSearchSuggestions(query, List.of()); + + assertThat(result).isFalse(); + } + + @Test + public void testCacheSearchSuggestions() { + final SearchSuggestionsQuery query = new SearchSuggestionsQuery(getQueryArgs("")); + final String mediaSetId = "media-set-id"; + final SearchSuggestion suggestion = new SearchSuggestion( + "search-text", + mediaSetId, + SearchProvider.AUTHORITY, + SEARCH_SUGGESTION_ALBUM, + null); + + final boolean result = SearchSuggestionsProvider + .maybeCacheSearchSuggestions(query, List.of(suggestion)); + + assertThat(result).isTrue(); + + final List<SearchSuggestion> searchSuggestionsResult = + SearchSuggestionsDatabaseUtils.getCachedSuggestions(mDatabase, query); + + assertWithMessage("Search suggestions should not be null") + .that(searchSuggestionsResult) + .isNotNull(); + + assertWithMessage("Search suggestions size is not as expected") + .that(searchSuggestionsResult.size()) + .isEqualTo(1); + + assertWithMessage("Search suggestion media set id is not as expected") + .that(searchSuggestionsResult.get(0).getMediaSetId()) + .isEqualTo(mediaSetId); + } + + @Test + public void testSuggestionsToCursor() { + final String authority = "authority"; + final SearchSuggestion albumSuggestion = new SearchSuggestion( + "album", + "album-set-id", + authority, + SEARCH_SUGGESTION_ALBUM, + null); + final SearchSuggestion faceSuggestion = new SearchSuggestion( + null, + "face-set-id", + authority, + SEARCH_SUGGESTION_FACE, + "id"); + final SearchSuggestion historySuggestion = new SearchSuggestion( + "history", + null, + null, + SEARCH_SUGGESTION_HISTORY, + null); + + try (Cursor cursor = SearchSuggestionsProvider + .suggestionsToCursor(List.of(albumSuggestion, faceSuggestion, historySuggestion))) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(3); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.SearchSuggestionsResponseColumns + .MEDIA_SET_ID.getProjection()))) + .isEqualTo("album-set-id"); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.SearchSuggestionsResponseColumns + .MEDIA_SET_ID.getProjection()))) + .isEqualTo("face-set-id"); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.SearchSuggestionsResponseColumns + .MEDIA_SET_ID.getProjection()))) + .isNull(); + } + } + + @Test + public void testSuggestionsFromCloudProvider() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + doReturn(true).when(mSearchState).isCloudSearchEnabled(any()); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsProvider.getSuggestionsFromCloudProvider( + mContext, new SearchSuggestionsQuery(getQueryArgs("")), null); + + assertWithMessage("Suggestions should not be null") + .that(searchSuggestions) + .isNotNull(); + + assertWithMessage("Suggestions size is not as expected") + .that(searchSuggestions.size()) + .isEqualTo(SearchProvider.DEFAULT_SUGGESTION_RESULTS.getCount()); + + SearchProvider.DEFAULT_SUGGESTION_RESULTS.moveToFirst(); + for (int iterator = 0; iterator < searchSuggestions.size(); iterator++) { + assertWithMessage("Media ID is not as expected") + .that(searchSuggestions.get(iterator).getMediaSetId()) + .isEqualTo(SearchProvider.DEFAULT_SUGGESTION_RESULTS.getString( + SearchProvider.DEFAULT_SUGGESTION_RESULTS.getColumnIndexOrThrow( + PickerSQLConstants.SearchSuggestionsResponseColumns + .MEDIA_SET_ID.getProjection()))); + + SearchProvider.DEFAULT_SUGGESTION_RESULTS.moveToNext(); + } + } + + @Test + public void testSuggestionsFromInactiveCloudProvider() { + doReturn(false).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(false).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + doReturn(true).when(mSearchState).isCloudSearchEnabled(any()); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsProvider.getSuggestionsFromCloudProvider( + mContext, new SearchSuggestionsQuery(getQueryArgs("")), null); + + assertWithMessage("Suggestions should not be null") + .that(searchSuggestions) + .isNotNull(); + + assertWithMessage("Suggestions size is not as expected") + .that(searchSuggestions.size()) + .isEqualTo(0); + } + + @Test + public void testSuggestionsFromCloudProviderWithSearchDisabled() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + doReturn(false).when(mSearchState).isCloudSearchEnabled(any()); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsProvider.getSuggestionsFromCloudProvider( + mContext, new SearchSuggestionsQuery(getQueryArgs("")), null); + + assertWithMessage("Suggestions should not be null") + .that(searchSuggestions) + .isNotNull(); + + assertWithMessage("Suggestions size is not as expected") + .that(searchSuggestions.size()) + .isEqualTo(0); + } + + private Bundle getQueryArgs(@Nullable String prefix) { + return getQueryArgs(prefix, new ArrayList<>(List.of(SearchProvider.AUTHORITY))); + } + + private Bundle getQueryArgs(@Nullable String prefix, @NonNull ArrayList<String> providers) { + final Bundle bundle = new Bundle(); + bundle.putString("prefix", prefix); + bundle.putStringArrayList("providers", providers); + return bundle; + } +} diff --git a/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaGroupCursorUtilsTest.java b/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaGroupCursorUtilsTest.java new file mode 100644 index 000000000..5a8920674 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaGroupCursorUtilsTest.java @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.v2; + +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_2; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_PROVIDER; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.assertAddMediaOperation; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getCloudMediaCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getLocalMediaCursor; + +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.UserHandle; + +import androidx.test.InstrumentationRegistry; + +import com.android.providers.media.cloudproviders.SearchProvider; +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; +import com.android.providers.media.photopicker.v2.sqlite.MediaGroupCursorUtils; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.File; +import java.util.List; +import java.util.Map; + +public class MediaGroupCursorUtilsTest { + @Mock + private PickerSyncController mMockSyncController; + private PickerDbFacade mFacade; + private Context mContext; + + @Before + public void setUp() { + initMocks(this); + PickerSyncController.setInstance(mMockSyncController); + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + mFacade = new PickerDbFacade(mContext, new PickerSyncLockManager(), LOCAL_PROVIDER); + mFacade.setCloudProvider(SearchProvider.AUTHORITY); + + doReturn(mFacade).when(mMockSyncController).getDbFacade(); + doReturn(LOCAL_PROVIDER).when(mMockSyncController).getLocalProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController).getCloudProvider(); + doReturn(SearchProvider.AUTHORITY).when(mMockSyncController) + .getCloudProviderOrDefault(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + } + + @After + public void tearDown() { + if (mFacade != null) { + mFacade.setCloudProvider(null); + } + } + + @Test + public void testGetLocalIdForCloudUri() { + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_1, LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, SearchProvider.AUTHORITY, cursor2, 1); + final Cursor cursor3 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor3, 1); + + final List<String> mediaUris = List.of( + "content://" + SearchProvider.AUTHORITY + "/" + CLOUD_ID_1, + "content://" + LOCAL_PROVIDER + "/" + LOCAL_ID_1 + ); + + final Map<String, String> result = MediaGroupCursorUtils.getLocalIds(mediaUris); + + assertWithMessage("Result map should not be null") + .that(result) + .isNotNull(); + assertWithMessage("Result map size is not as expected") + .that(result.size()) + .isEqualTo(1); + assertWithMessage("Result map should contain cloud id as key") + .that(result.containsKey(CLOUD_ID_1)) + .isTrue(); + assertWithMessage("Mapped local id is incorrect") + .that(result.get(CLOUD_ID_1)) + .isEqualTo(LOCAL_ID_1); + } + + @Test + public void testGetLocalIdForCloudUriNoMatch() { + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_1, /* localId */ null, 0); + assertAddMediaOperation(mFacade, SearchProvider.AUTHORITY, cursor2, 1); + final Cursor cursor3 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor3, 1); + + final List<String> mediaUris = List.of( + "content://" + SearchProvider.AUTHORITY + "/" + CLOUD_ID_1 + ); + + final Map<String, String> result = MediaGroupCursorUtils.getLocalIds(mediaUris); + + assertWithMessage("Result map should not be null") + .that(result) + .isNotNull(); + assertWithMessage("Result map size is not as expected") + .that(result.size()) + .isEqualTo(0); + } + + @Test + public void testGetValidLocalIdForCloudUri() { + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_1, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, SearchProvider.AUTHORITY, cursor2, 1); + + final List<String> mediaUris = List.of( + "content://" + SearchProvider.AUTHORITY + "/" + CLOUD_ID_1 + ); + + final Map<String, String> result = MediaGroupCursorUtils.getLocalIds(mediaUris); + + assertWithMessage("Result map should not be null") + .that(result) + .isNotNull(); + assertWithMessage("Result map size is not as expected") + .that(result.size()) + .isEqualTo(0); + } + + @Test + public void testGetValidLocalIdForEmptyUriList() { + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_1, /* localId */ null, 0); + assertAddMediaOperation(mFacade, SearchProvider.AUTHORITY, cursor2, 1); + final Cursor cursor3 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor3, 1); + + final List<String> mediaUris = List.of(); + + final Map<String, String> result = MediaGroupCursorUtils.getLocalIds(mediaUris); + + assertWithMessage("Result map should not be null") + .that(result) + .isNotNull(); + assertWithMessage("Result map size is not as expected") + .that(result.size()) + .isEqualTo(0); + } + + @Test + public void testGetLocalUri() { + final String cloudAuthority = "cloud.authority"; + final String cloudMediaId = "cloud-id"; + final String localMediaId = "local-id"; + final Uri cloudUri = new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .encodedAuthority(cloudAuthority) + .appendPath("media") + .appendPath(cloudMediaId) + .build(); + + final String localAuthority = PickerSyncController.getInstanceOrThrow().getLocalProvider(); + final Uri expectedLocalUri = new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .encodedAuthority(UserHandle.myUserId() + "@" + localAuthority) + .appendPath("media") + .appendPath(localMediaId) + .build(); + + final String actualLocalUri = MediaGroupCursorUtils.maybeGetLocalUri( + cloudUri.toString(), Map.of(cloudMediaId, localMediaId)); + + assertWithMessage("Mapped local uri is not as expected.") + .that(actualLocalUri) + .isEqualTo(expectedLocalUri.toString()); + } + + @Test + public void testGetLocalUriWithNoMapping() { + final String cloudAuthority = "cloud.authority"; + final String cloudMediaId = "cloud-id"; + final Uri cloudUri = new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .encodedAuthority(cloudAuthority) + .appendPath("media") + .appendPath(cloudMediaId) + .build(); + + final String actualUri = MediaGroupCursorUtils.maybeGetLocalUri( + cloudUri.toString(), Map.of()); + + assertWithMessage("Returned uri is not as expected.") + .that(actualUri) + .isEqualTo(cloudUri.toString()); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsDatabaseUtilTest.java b/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsDatabaseUtilTest.java new file mode 100644 index 000000000..f132eb626 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsDatabaseUtilTest.java @@ -0,0 +1,664 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.v2.sqlite; + +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_2; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_3; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_PROVIDER; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.DATE_TAKEN_MS; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.GENERATION_MODIFIED; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.GIF_IMAGE_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.JPEG_IMAGE_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_2; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_3; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_4; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_PROVIDER; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.MP4_VIDEO_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.PNG_IMAGE_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.STANDARD_MIME_TYPE_EXTENSION; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.assertAddMediaOperation; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getCloudMediaCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getLocalMediaCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getMediaCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.toMediaStoreUri; + +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.provider.MediaStore; + +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MediaInMediaSetsDatabaseUtilTest { + + @Mock + private PickerSyncController mMockSyncController; + private SQLiteDatabase mDatabase; + private Context mContext; + private PickerDbFacade mFacade; + + @Before + public void setUp() { + initMocks(this); + PickerSyncController.setInstance(mMockSyncController); + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + mDatabase = helper.getWritableDatabase(); + mFacade = new PickerDbFacade(mContext, new PickerSyncLockManager(), LOCAL_PROVIDER); + mFacade.setCloudProvider(CLOUD_PROVIDER); + doReturn(mFacade).when(mMockSyncController).getDbFacade(); + doReturn(LOCAL_PROVIDER).when(mMockSyncController).getLocalProvider(); + } + + @After + public void teardown() { + mDatabase.close(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + } + + @Test + public void testQueryLocalMediaInMediaSet() { + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor1, 1); + final Cursor cursor2 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + Long mediaSetPickerId = 1L; + + int cloudRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId) + ), CLOUD_PROVIDER + ); + assertEquals( + "Number of rows inserted should be equal to the number of items in the cursor,", + /*expected*/cloudRowsInserted, + /*actual*/1); + + int localRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(LOCAL_ID_1, null, mediaSetPickerId) + ), LOCAL_PROVIDER + ); + assertEquals( + "Number of rows inserted is incorrect", + localRowsInserted, + 1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + MediaInMediaSetsQuery mediaInMediaSetQuery = new MediaInMediaSetsQuery( + extras, mediaSetPickerId); + Cursor mediaCursor = MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet( + mMockSyncController, mediaInMediaSetQuery, LOCAL_PROVIDER, CLOUD_PROVIDER); + assertNotNull(mediaCursor); + assertEquals(mediaCursor.getCount(), 2); + + mediaCursor.moveToFirst(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_2); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_1); + mediaCursor.moveToNext(); + } + + @Test + public void testQueryCloudMediaInMediaSet() { + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + Long mediaSetPickerId = 1L; + + final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_3, mediaSetPickerId), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, mediaSetPickerId) + ), CLOUD_PROVIDER); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + MediaInMediaSetsQuery mediaInMediaSetQuery = new MediaInMediaSetsQuery( + extras, mediaSetPickerId); + Cursor mediaCursor = MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet( + mMockSyncController, mediaInMediaSetQuery, LOCAL_PROVIDER, CLOUD_PROVIDER); + assertNotNull(mediaCursor); + assertEquals(mediaCursor.getCount(), 3); + + mediaCursor.moveToFirst(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + } + + @Test + public void testQueryMediaInMediaSetForSpecificMediaSetPickerId() { + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + Long mediaSetPickerId1 = 1L; + Long mediaSetPickerId2 = 2L; + + final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_3, mediaSetPickerId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId2), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, mediaSetPickerId2) + ), CLOUD_PROVIDER); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + MediaInMediaSetsQuery mediaInMediaSetQuery = new MediaInMediaSetsQuery( + extras, mediaSetPickerId2); + Cursor mediaCursor = MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet( + mMockSyncController, mediaInMediaSetQuery, LOCAL_PROVIDER, CLOUD_PROVIDER); + assertNotNull(mediaCursor); + assertEquals(mediaCursor.getCount(), 2); + + mediaCursor.moveToFirst(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + } + + @Test + public void testQueryMediaInMediaSetsSortOrder() { + final long dateTaken = 0L; + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, dateTaken + 1); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, dateTaken); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, dateTaken - 1); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, dateTaken); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); + + Long mediaSetPickerId = 1L; + + final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_3, mediaSetPickerId), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, mediaSetPickerId) + ), CLOUD_PROVIDER); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + int localRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(LOCAL_ID_4, null, mediaSetPickerId) + ), LOCAL_PROVIDER + ); + assertEquals( + "Number of rows inserted is incorrect", + localRowsInserted, + 1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + MediaInMediaSetsQuery mediaInMediaSetQuery = new MediaInMediaSetsQuery( + extras, mediaSetPickerId); + Cursor mediaCursor = MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet( + mMockSyncController, mediaInMediaSetQuery, LOCAL_PROVIDER, CLOUD_PROVIDER); + assertNotNull(mediaCursor); + assertEquals(mediaCursor.getCount(), 4); + + mediaCursor.moveToFirst(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_4); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + } + + @Test + public void testQueryMediaInMediaSetsPagination() { + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + Long mediaSetPickerId = 1L; + + final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_3, mediaSetPickerId), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, mediaSetPickerId) + ), CLOUD_PROVIDER); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 2); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + MediaInMediaSetsQuery mediaInMediaSetQuery = new MediaInMediaSetsQuery( + extras, mediaSetPickerId); + Cursor mediaCursor = MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet( + mMockSyncController, mediaInMediaSetQuery, LOCAL_PROVIDER, CLOUD_PROVIDER); + assertNotNull(mediaCursor); + assertEquals(mediaCursor.getCount(), 2); + + mediaCursor.moveToFirst(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + mediaCursor.moveToNext(); + } + + @Test + public void testQueryMediaInMediaSetsMimeTypeFilter() { + final Cursor cursor1 = getMediaCursor(CLOUD_ID_1, DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ null, /* sizeBytes */ 1, MP4_VIDEO_MIME_TYPE, + STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getMediaCursor(CLOUD_ID_2, DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ toMediaStoreUri(LOCAL_ID_2), /* sizeBytes */ 1, + PNG_IMAGE_MIME_TYPE, STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getMediaCursor(CLOUD_ID_3, DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ toMediaStoreUri(LOCAL_ID_3), /* sizeBytes */ 1, + GIF_IMAGE_MIME_TYPE, STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + final Cursor cursor4 = getMediaCursor(LOCAL_ID_4, DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ toMediaStoreUri(LOCAL_ID_4), /* sizeBytes */ 1, + JPEG_IMAGE_MIME_TYPE, STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); + + Long mediaSetPickerId = 1L; + + final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_3, mediaSetPickerId), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, mediaSetPickerId) + ), CLOUD_PROVIDER); + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + int localRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(LOCAL_ID_4, null, mediaSetPickerId) + ), LOCAL_PROVIDER + ); + assertEquals( + "Number of rows inserted is incorrect", + localRowsInserted, + 1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + extras.putStringArrayList("mime_types", new ArrayList<>(List.of("video/mp4", "image/gif"))); + MediaInMediaSetsQuery mediaInMediaSetQuery = new MediaInMediaSetsQuery( + extras, mediaSetPickerId); + Cursor mediaCursor = MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet( + mMockSyncController, mediaInMediaSetQuery, LOCAL_PROVIDER, CLOUD_PROVIDER); + assertNotNull(mediaCursor); + assertEquals(mediaCursor.getCount(), 2); + + mediaCursor.moveToFirst(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + mediaCursor.moveToNext(); + } + + @Test + public void testQueryMediaInMediaSetsLocalProviderFilter() { + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); + + Long mediaSetPickerId = 1L; + + final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_3, mediaSetPickerId), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, mediaSetPickerId) + ), CLOUD_PROVIDER); + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + int localRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(LOCAL_ID_4, null, mediaSetPickerId) + ), LOCAL_PROVIDER + ); + assertEquals( + "Number of rows inserted is incorrect", + localRowsInserted, + 1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + MediaInMediaSetsQuery mediaInMediaSetQuery = new MediaInMediaSetsQuery( + extras, mediaSetPickerId); + Cursor mediaCursor = MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet( + mMockSyncController, mediaInMediaSetQuery, LOCAL_PROVIDER, null); + assertNotNull(mediaCursor); + assertEquals(mediaCursor.getCount(), 1); + + mediaCursor.moveToFirst(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_4); + } + + @Test + public void testQueryMediaInMediaSetsCloudProviderFilter() { + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); + + Long mediaSetPickerId = 1L; + + final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_3, mediaSetPickerId), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, mediaSetPickerId) + ), CLOUD_PROVIDER); + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + int localRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(LOCAL_ID_4, null, mediaSetPickerId) + ), LOCAL_PROVIDER + ); + assertEquals( + "Number of rows inserted is incorrect", + localRowsInserted, + 1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + MediaInMediaSetsQuery mediaInMediaSetQuery = new MediaInMediaSetsQuery( + extras, mediaSetPickerId); + Cursor mediaCursor = MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet( + mMockSyncController, mediaInMediaSetQuery, null, CLOUD_PROVIDER); + assertNotNull(mediaCursor); + assertEquals(mediaCursor.getCount(), 3); + + mediaCursor.moveToFirst(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + mediaCursor.moveToNext(); + + assertWithMessage("Media ID is not as expected in the search results") + .that(mediaCursor.getString(mediaCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + mediaCursor.moveToNext(); + } + + @Test + public void testCacheMediaInMediaSet() { + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + Long mediaSetPickerId = 1L; + + int cloudRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_1, mediaSetPickerId), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId) + ), CLOUD_PROVIDER + ); + assertEquals( + "Number of rows inserted is incorrect", + cloudRowsInserted, + 2); + + // Try to insert the item with same LOCAL_ID as before + int localRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(LOCAL_ID_2, null, mediaSetPickerId) + ), LOCAL_PROVIDER + ); + assertEquals( + "Number of rows inserted is incorrect", + localRowsInserted, + 1); + } + + @Test + public void testClearMediaInMediaSetCache() { + // Insert data + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + Long mediaSetPickerId = 1L; + + final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( + mDatabase, List.of( + getContentValues(null, CLOUD_ID_3, mediaSetPickerId), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, mediaSetPickerId), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, mediaSetPickerId) + ), CLOUD_PROVIDER); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + // Clear the data + MediaInMediaSetsDatabaseUtil.clearMediaInMediaSetsCache(mDatabase); + + // Retrieved cursor should be empty + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + MediaInMediaSetsQuery mediaInMediaSetQuery = new MediaInMediaSetsQuery( + extras, mediaSetPickerId); + Cursor mediaCursor = MediaInMediaSetsDatabaseUtil.queryMediaInMediaSet( + mMockSyncController, mediaInMediaSetQuery, LOCAL_PROVIDER, CLOUD_PROVIDER); + assertNotNull(mediaCursor); + assertEquals(/*expected*/0, /*actual*/ mediaCursor.getCount()); + + } + + private ContentValues getContentValues( + String localId, String cloudId, Long mediaSetPickerId) { + ContentValues contentValues = new ContentValues(); + contentValues.put( + PickerSQLConstants.MediaInMediaSetsTableColumns.CLOUD_ID.getColumnName(), cloudId); + contentValues.put( + PickerSQLConstants.MediaInMediaSetsTableColumns.LOCAL_ID.getColumnName(), localId); + contentValues.put( + PickerSQLConstants.MediaInMediaSetsTableColumns.MEDIA_SETS_PICKER_ID + .getColumnName(), + mediaSetPickerId); + return contentValues; + } +} diff --git a/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaSetsDatabaseUtilsTest.java b/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaSetsDatabaseUtilsTest.java new file mode 100644 index 000000000..72ccb7795 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaSetsDatabaseUtilsTest.java @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.v2.sqlite; + +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.provider.CloudMediaProviderContract; +import android.util.Pair; + +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.v2.model.MediaSetsSyncRequestParams; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MediaSetsDatabaseUtilsTest { + private SQLiteDatabase mDatabase; + private Context mContext; + private final String mMediaSetId = "mediaSetId"; + private final String mCategoryId = "categoryId"; + private final String mAuthority = "auth"; + private final String mMimeType = "img"; + private final String mDisplayName = "name"; + private final String mCoverId = "id"; + + @Before + public void setUp() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + mDatabase = helper.getWritableDatabase(); + } + + @After + public void teardown() { + mDatabase.close(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + } + + @Test + public void testInsertMediaSetMetadataIntoMediaSetsTable() { + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add(mMimeType); + + int mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, mCategoryId, mAuthority, mimeTypes); + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); + } + + @Test + public void testInsertMediaSetMetadataIntoMediaTableMimeTypeFilter() { + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> firstMimeTypeFilter = new ArrayList<>(); + firstMimeTypeFilter.add("image/*"); + firstMimeTypeFilter.add("video/*"); + + int firstInsertionCount = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, mCategoryId, mAuthority, firstMimeTypeFilter); + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ firstInsertionCount); + + // Reversing the order of the mimeTypeFilter. + // It should still be treated the same and should not be reinserted + List<String> secondMimeTypeFilter = new ArrayList<>(); + secondMimeTypeFilter.add("video/*"); + secondMimeTypeFilter.add("image/*"); + + int secondInsertionCount = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, mCategoryId, mAuthority, secondMimeTypeFilter); + assertEquals("MediaSet metadata with same mimetype filters should not be inserted " + + "again", + /*expected*/ 0, /*actual*/ secondInsertionCount); + + } + + @Test + public void testInsertMediaSetMetadataWhenMediaSetIdIsNull() { + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add(mMimeType); + + String[] columns = new String[]{ + CloudMediaProviderContract.MediaSetColumns.ID, + CloudMediaProviderContract.MediaSetColumns.DISPLAY_NAME, + CloudMediaProviderContract.MediaSetColumns.MEDIA_COVER_ID + }; + + MatrixCursor cursor = new MatrixCursor(columns); + cursor.addRow(new Object[] { null, mDisplayName, mCoverId }); + + int mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, cursor, mCategoryId, mAuthority, mimeTypes); + assertEquals("Count of inserted media sets should be 0 when the mediaSetId is null", + /*expected*/0, /*actual*/ mediaSetsInserted); + } + + @Test + public void testGetMediaSetMetadataForCategory() { + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add(mMimeType); + + long insertResult = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, mCategoryId, mAuthority, mimeTypes); + // Assert successful insertion + assertWithMessage("MediaSet metadata insertion failed") + .that(insertResult) + .isAtLeast(/* expected min row id */ 0); + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, mAuthority); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, mCategoryId); + extras.putStringArrayList( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(mimeTypes)); + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + + Cursor mediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( + mDatabase, requestParams); + assertNotNull(mediaSetCursor); + assertWithMessage("Cursor size should be greater than 0. Expected size: 1") + .that(mediaSetCursor.getCount()) + .isEqualTo(1); + if (mediaSetCursor.moveToFirst()) { + int mediaSetIdIndex = mediaSetCursor.getColumnIndex(PickerSQLConstants + .MediaSetsTableColumns.MEDIA_SET_ID.getColumnName()); + String retrievedMediaSetId = mediaSetCursor.getString(mediaSetIdIndex); + assertEquals(mMediaSetId, retrievedMediaSetId); + } + } + + @Test + public void testUpdateAndGetMediaInMediaSetResumeKey() { + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add(mMimeType); + + long mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, mCategoryId, mAuthority, mimeTypes); + // Assert successful insertion + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, mAuthority); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, mCategoryId); + extras.putStringArrayList( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(mimeTypes)); + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( + mDatabase, requestParams); + Long mediaSetPickerId = 1L; + if (fetchMediaSetCursor.moveToFirst()) { + mediaSetPickerId = fetchMediaSetCursor.getLong( + fetchMediaSetCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaSetsTableColumns.PICKER_ID.getColumnName())); + } + + String resumeKey = "resume"; + MediaSetsDatabaseUtil.updateMediaInMediaSetSyncResumeKey( + mDatabase, mediaSetPickerId, resumeKey); + String retrievedMediaSetResumeKey = MediaSetsDatabaseUtil.getMediaResumeKey( + mDatabase, mediaSetPickerId); + assertNotNull(retrievedMediaSetResumeKey); + assertWithMessage("Retrieved mediaSetResumeKey did not match") + .that(retrievedMediaSetResumeKey) + .isEqualTo(resumeKey); + } + + @Test + public void testGetMediaSetIdAndMimeTypesUsingMediaSetPickerId() { + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add(mMimeType); + + long mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, mCategoryId, mAuthority, mimeTypes); + // Assert successful insertion + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, mAuthority); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, mCategoryId); + extras.putStringArrayList( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(mimeTypes)); + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( + mDatabase, requestParams); + Long mediaSetPickerId = 1L; + if (fetchMediaSetCursor.moveToFirst()) { + mediaSetPickerId = fetchMediaSetCursor.getLong( + fetchMediaSetCursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaSetsTableColumns.PICKER_ID.getColumnName())); + } + + Pair<String, String[]> retrievedData = MediaSetsDatabaseUtil + .getMediaSetIdAndMimeType(mDatabase, mediaSetPickerId); + assertEquals(/*expected*/retrievedData.first, /*actual*/mMediaSetId); + assertTrue(Arrays.toString(retrievedData.second).contains(mMimeType)); + } + + @Test + public void testClearMediaSetsCache() { + // Insert metadata into the table + Cursor c = getCursorForMediaSetInsertionTest(); + List<String> mimeTypes = new ArrayList<>(); + mimeTypes.add(mMimeType); + + int mediaSetsInserted = MediaSetsDatabaseUtil.cacheMediaSets( + mDatabase, c, mCategoryId, mAuthority, mimeTypes); + assertEquals("Count of inserted media sets should be equal to the cursor size", + /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); + + // Delete the inserted items + MediaSetsDatabaseUtil.clearMediaSetsCache(mDatabase); + + // Retrieved cursor should be empty + Bundle extras = new Bundle(); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, mAuthority); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, mCategoryId); + extras.putStringArrayList( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + new ArrayList<String>(mimeTypes)); + MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); + + Cursor mediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( + mDatabase, requestParams); + assertNotNull(mediaSetCursor); + assertEquals(/*expected*/ 0, /*actual*/ mediaSetCursor.getCount()); + } + + private Cursor getCursorForMediaSetInsertionTest() { + String[] columns = new String[]{ + CloudMediaProviderContract.MediaSetColumns.ID, + CloudMediaProviderContract.MediaSetColumns.DISPLAY_NAME, + CloudMediaProviderContract.MediaSetColumns.MEDIA_COVER_ID + }; + + MatrixCursor cursor = new MatrixCursor(columns); + cursor.addRow(new Object[] { mMediaSetId, mDisplayName, mCoverId }); + + return cursor; + } +} diff --git a/tests/src/com/android/providers/media/photopicker/v2/sqlite/SearchRequestDatabaseUtilTest.java b/tests/src/com/android/providers/media/photopicker/v2/sqlite/SearchRequestDatabaseUtilTest.java new file mode 100644 index 000000000..2ddbd7b99 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/v2/sqlite/SearchRequestDatabaseUtilTest.java @@ -0,0 +1,572 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.v2.sqlite; + +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_LOCATION; +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_TEXT; + +import static com.google.common.truth.Truth.assertWithMessage; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; + +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.v2.model.SearchRequest; +import com.android.providers.media.photopicker.v2.model.SearchSuggestionRequest; +import com.android.providers.media.photopicker.v2.model.SearchTextRequest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.util.List; + +public class SearchRequestDatabaseUtilTest { + private SQLiteDatabase mDatabase; + private Context mContext; + + @Before + public void setUp() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + mDatabase = helper.getWritableDatabase(); + } + + @After + public void teardown() { + mDatabase.close(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + } + + @Test + public void testInsertSearchTextRequest() { + final SearchTextRequest searchRequest = new SearchTextRequest( + /* mimeTypes */ null, + "mountains" + ); + + final long firstInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest); + assertWithMessage("Insert search request failed") + .that(firstInsertResult) + .isAtLeast(/* minimum row id */ 0); + + final long secondInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest); + assertWithMessage("Second insert for same search request should fail silently") + .that(secondInsertResult) + .isEqualTo(/* failed to insert row on constraint conflict */ -1); + } + + @Test + public void testInsertSearchSuggestionRequest() { + final SearchSuggestionRequest suggestionRequest = new SearchSuggestionRequest( + /* mimeTypes */ null, + "mountains", + "media-set-id", + "authority", + SEARCH_SUGGESTION_TEXT + ); + + final long firstInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, suggestionRequest); + assertWithMessage("Insert search request failed") + .that(firstInsertResult) + .isAtLeast(/* minimum row id */ 0); + + final long secondInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, suggestionRequest); + assertWithMessage("Second insert for same search request should fail silently") + .that(secondInsertResult) + .isEqualTo(/* failed to insert row on constraint conflict */ -1); + } + + @Test + public void testInsertSearchRequestsWithSameQuery() { + // Insert a search text request with "mountains" search text. This insert should be + // successful. + final SearchTextRequest searchRequest1 = new SearchTextRequest( + /* mimeTypes */ null, + "mountains" + ); + + final long firstInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest1); + assertWithMessage("Insert search request failed") + .that(firstInsertResult) + .isAtLeast(/* minimum row id */ 0); + + // Insert search suggestion request with "mountains" search text. This insert should be + // successful. + final SearchSuggestionRequest searchRequest2 = new SearchSuggestionRequest( + /* mimeTypes */ null, + "mountains", + "media-set-id", + "authority", + SEARCH_SUGGESTION_TEXT + ); + + final long secondInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest2); + assertWithMessage("Insert search request failed") + .that(secondInsertResult) + .isAtLeast(/* minimum row id */ 0); + + // Insert search text request with "Mountains" search text. This insert should be + // successful since search text is text sensitive. + final SearchTextRequest searchRequest3 = new SearchTextRequest( + /* mimeTypes */ null, + "Mountains" + ); + + final long thirdInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest3); + assertWithMessage("Insert search request failed") + .that(thirdInsertResult) + .isAtLeast(/* minimum row id */ 0); + + // Insert search text request with "mountains" search text but a different media set id + // than before. This insert should be successful since search text is text sensitive. + final SearchSuggestionRequest searchRequest4 = new SearchSuggestionRequest( + /* mimeTypes */ null, + "mountains", + "different-media-set-id", + "authority", + SEARCH_SUGGESTION_TEXT + ); + + final long fourthInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest4); + assertWithMessage("Insert search request failed") + .that(fourthInsertResult) + .isAtLeast(/* minimum row id */ 0); + } + + @Test + public void testMimeTypeUniqueConstraintSearchRequest() { + SearchTextRequest request = new SearchTextRequest( + /* mimeTypes */ List.of("image/*", "video/*", "image/gif"), + /* searchText */ "volcano" + ); + + final long firstInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, request); + assertWithMessage("Insert search request failed") + .that(firstInsertResult) + .isAtLeast(/* minimum row id */ 0); + + request = new SearchTextRequest( + /* mimeTypes */ List.of("image/gif", "video/*", "image/*"), + /* searchText */ "volcano" + ); + final long secondInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, request); + assertWithMessage("Second insert for same search request should fail silently") + .that(secondInsertResult) + .isEqualTo(/* failed to insert row on constraint conflict */ -1); + + request = new SearchTextRequest( + /* mimeTypes */ List.of("image/GIF", "Video/*", "IMAGE/*"), + /* searchText */ "volcano" + ); + final long thirdInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, request); + assertWithMessage("Third insert for same search request should fail silently") + .that(thirdInsertResult) + .isEqualTo(/* failed to insert row on constraint conflict */ -1); + } + + @Test + public void testGetSearchRequestID() { + SearchTextRequest searchRequest = new SearchTextRequest( + /* mimeTypes */ null, + "mountains" + ); + assertWithMessage("Search request should not exist in database yet") + .that(SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest)) + .isEqualTo(/* expectedRequestID */ -1); + + + final long firstInsertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest); + assertWithMessage("Insert search request failed") + .that(firstInsertResult) + .isAtLeast(/* minimum row id */ 0); + + assertWithMessage("Search request ID should exist in DB") + .that(SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest)) + .isAtLeast(0); + + searchRequest = new SearchTextRequest( + /* mimeTypes */ List.of("image/*"), + "mountains" + ); + assertWithMessage("Search request should not exist in database for the given mime types") + .that(SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest)) + .isEqualTo(/* expectedRequestID */ -1); + } + + @Test + public void testGetSearchTextRequestDetails() { + final List<String> mimeTypes = List.of("video/mp4", "image/*", "image/gif"); + final String searchText = "mountains"; + final String cloudResumeKey = "RANDOM_RESUME_KEY_CLOUD"; + final String localResumeKey = "RANDOM_RESUME_KEY_LOCAL"; + final String cloudAuthority = "com.random.cloud.authority"; + final String localAuthority = "com.random.local.authority"; + SearchTextRequest searchRequest = new SearchTextRequest( + mimeTypes, + searchText, + localResumeKey, + localAuthority, + cloudResumeKey, + cloudAuthority + ); + + // Insert a search request + final long insertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest); + assertWithMessage("Insert search request failed") + .that(insertResult) + .isAtLeast(/* minimum row id */ 0); + + // Get search request ID + final int searchRequestID = + SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest); + assertWithMessage("Search request ID should exist in DB") + .that(searchRequestID) + .isAtLeast(0); + + // Fetch search details from search request ID + final SearchRequest resultSearchRequest = + SearchRequestDatabaseUtil.getSearchRequestDetails(mDatabase, searchRequestID); + assertWithMessage("Unable to fetch search details from the database") + .that(resultSearchRequest) + .isNotNull(); + assertWithMessage("Search request should be an instance of SearchTextRequest") + .that(resultSearchRequest) + .isInstanceOf(SearchTextRequest.class); + assertWithMessage("Search request mime types are not as expected") + .that(resultSearchRequest.getMimeTypes()) + .containsExactlyElementsIn(mimeTypes); + assertWithMessage("Search request cloud resume key is not as expected") + .that(resultSearchRequest.getCloudSyncResumeKey()) + .isEqualTo(cloudResumeKey); + assertWithMessage("Search request cloud authority is not as expected") + .that(resultSearchRequest.getCloudAuthority()) + .isEqualTo(cloudAuthority); + assertWithMessage("Search request local resume key is not as expected") + .that(resultSearchRequest.getLocalSyncResumeKey()) + .isEqualTo(localResumeKey); + assertWithMessage("Search request local authority is not as expected") + .that(resultSearchRequest.getLocalAuthority()) + .isEqualTo(localAuthority); + + final SearchTextRequest resultSearchTextRequest = (SearchTextRequest) resultSearchRequest; + assertWithMessage("Search request search text is not as expected") + .that(resultSearchTextRequest.getSearchText()) + .isEqualTo(searchText); + } + + @Test + public void testGetSearchSuggestionRequestDetails() { + final List<String> mimeTypes = List.of("video/mp4", "image/*", "image/gif"); + final String cloudResumeKey = "RANDOM_RESUME_KEY_CLOUD"; + final String localResumeKey = "RANDOM_RESUME_KEY_LOCAL"; + final String mediaSetID = "MEDIA-SET-ID"; + final String cloudAuthority = "com.random.cloud.authority"; + final String localAuthority = "com.random.local.authority"; + final String suggestionType = SEARCH_SUGGESTION_LOCATION; + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + mimeTypes, + null, + mediaSetID, + cloudAuthority, + suggestionType, + localResumeKey, + localAuthority, + cloudResumeKey, + cloudAuthority + ); + + // Insert a search request + final long insertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest); + assertWithMessage("Insert search request failed") + .that(insertResult) + .isAtLeast(/* minimum row id */ 0); + + // Get search request ID + final int searchRequestID = + SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest); + assertWithMessage("Search request ID should exist in DB") + .that(searchRequestID) + .isAtLeast(0); + + // Fetch search details from search request ID + final SearchRequest resultSearchRequest = + SearchRequestDatabaseUtil.getSearchRequestDetails(mDatabase, searchRequestID); + assertWithMessage("Unable to fetch search details from the database") + .that(resultSearchRequest) + .isNotNull(); + assertWithMessage("Search request should be an instance of SearchSuggestionRequest") + .that(resultSearchRequest) + .isInstanceOf(SearchSuggestionRequest.class); + assertWithMessage("Search request mime types are not as expected") + .that(resultSearchRequest.getMimeTypes()) + .containsExactlyElementsIn(mimeTypes); + assertWithMessage("Search request cloud resume key is not as expected") + .that(resultSearchRequest.getCloudSyncResumeKey()) + .isEqualTo(cloudResumeKey); + assertWithMessage("Search request cloud authority is not as expected") + .that(resultSearchRequest.getCloudAuthority()) + .isEqualTo(cloudAuthority); + assertWithMessage("Search request local resume key is not as expected") + .that(resultSearchRequest.getLocalSyncResumeKey()) + .isEqualTo(localResumeKey); + assertWithMessage("Search request local authority is not as expected") + .that(resultSearchRequest.getLocalAuthority()) + .isEqualTo(localAuthority); + + final SearchSuggestionRequest resultSearchSuggestionRequest = + (SearchSuggestionRequest) resultSearchRequest; + assertWithMessage("Search request search text is not as expected") + .that(resultSearchSuggestionRequest.getSearchSuggestion().getSearchText()) + .isNull(); + assertWithMessage("Search request search text is not as expected") + .that(resultSearchSuggestionRequest.getSearchSuggestion().getMediaSetId()) + .isEqualTo(mediaSetID); + assertWithMessage("Search request search text is not as expected") + .that(resultSearchSuggestionRequest.getSearchSuggestion().getAuthority()) + .isEqualTo(cloudAuthority); + assertWithMessage("Search request search text is not as expected") + .that(resultSearchSuggestionRequest.getSearchSuggestion().getSearchSuggestionType()) + .isEqualTo(suggestionType); + } + + @Test + public void testResumeKeyUpdate() { + final List<String> mimeTypes = List.of("video/mp4", "image/*", "image/gif"); + final String mediaSetID = "MEDIA-SET-ID"; + final String authority = "com.random.authority"; + final String suggestionType = SEARCH_SUGGESTION_LOCATION; + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + mimeTypes, + null, + mediaSetID, + authority, + suggestionType + ); + + // Insert a search request + final long insertResult = + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest); + assertWithMessage("Insert search request failed") + .that(insertResult) + .isAtLeast(/* minimum row id */ 0); + + // Get search request ID + final int searchRequestID = + SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest); + assertWithMessage("Search request ID should exist in DB") + .that(searchRequestID) + .isAtLeast(0); + + // Fetch search details from search request ID + final SearchRequest savedSearchRequest = + SearchRequestDatabaseUtil.getSearchRequestDetails(mDatabase, searchRequestID); + assertWithMessage("Search request is null") + .that(savedSearchRequest) + .isNotNull(); + assertWithMessage("Initial search request cloud resume key is not null") + .that(savedSearchRequest.getCloudSyncResumeKey()) + .isNull(); + assertWithMessage("Initial search request cloud authority is not null") + .that(savedSearchRequest.getCloudAuthority()) + .isNull(); + assertWithMessage("Initial search request local sync resume key is not null") + .that(savedSearchRequest.getLocalSyncResumeKey()) + .isNull(); + assertWithMessage("Initial search request local authority is not null") + .that(savedSearchRequest.getLocalAuthority()) + .isNull(); + + // Update cloud resume key and save + final String cloudResumeKey = "CLOUD_RESUME_KEY"; + final String cloudAuthority = "CLOUD_AUTHORITY"; + SearchRequestDatabaseUtil.updateResumeKey(mDatabase, searchRequestID, cloudResumeKey, + cloudAuthority, /* isLocal */ false); + + // Fetch updated search details from search request ID + final SearchRequest updatedSearchRequest1 = + SearchRequestDatabaseUtil.getSearchRequestDetails(mDatabase, searchRequestID); + assertWithMessage("Search request is null") + .that(updatedSearchRequest1) + .isNotNull(); + assertWithMessage("Search request cloud resume key is not as expected") + .that(updatedSearchRequest1.getCloudSyncResumeKey()) + .isEqualTo(cloudResumeKey); + assertWithMessage("Search request cloud authority is not as expected") + .that(updatedSearchRequest1.getCloudAuthority()) + .isEqualTo(cloudAuthority); + assertWithMessage("Search request local sync resume key is not null") + .that(updatedSearchRequest1.getLocalSyncResumeKey()) + .isNull(); + assertWithMessage("Initial search request local authority is not null") + .that(updatedSearchRequest1.getLocalAuthority()) + .isNull(); + + // Update local resume key and save + final String localResumeKey = "LOCAL_RESUME_KEY"; + final String localAuthority = "LOCAL_AUTHORITY"; + SearchRequestDatabaseUtil.updateResumeKey(mDatabase, searchRequestID, localResumeKey, + localAuthority, /* isLocal */ true); + + // Clear cloud resume key + SearchRequestDatabaseUtil.clearSyncResumeInfo(mDatabase, List.of(searchRequestID), + /* isLocal */ false); + + // Fetch updated search details from search request ID + final SearchRequest updatedSearchRequest2 = + SearchRequestDatabaseUtil.getSearchRequestDetails(mDatabase, searchRequestID); + assertWithMessage("Search request is null") + .that(updatedSearchRequest2) + .isNotNull(); + assertWithMessage("Search request local resume key is not as expected") + .that(updatedSearchRequest2.getLocalSyncResumeKey()) + .isEqualTo(localResumeKey); + assertWithMessage("Search request local authority is not as expected") + .that(updatedSearchRequest2.getLocalAuthority()) + .isEqualTo(localAuthority); + assertWithMessage("Search request cloud sync resume key is not null") + .that(updatedSearchRequest2.getCloudSyncResumeKey()) + .isNull(); + assertWithMessage("Initial search request cloud authority is not null") + .that(updatedSearchRequest2.getCloudAuthority()) + .isNull(); + } + + @Test + public void testGetSyncedRequestIds() { + // Insert a search request in the database. + final SearchTextRequest searchRequest1 = new SearchTextRequest( + /* mimeTypes */ null, + "mountains" + ); + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest1); + + // Get search request ID + final int searchRequestID1 = + SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest1); + assertWithMessage("Search request ID should exist in DB") + .that(searchRequestID1) + .isAtLeast(0); + + // Update local resume key and save + final String localResumeKey = "LOCAL_RESUME_KEY"; + final String localAuthority = "LOCAL_AUTHORITY"; + SearchRequestDatabaseUtil.updateResumeKey(mDatabase, searchRequestID1, localResumeKey, + localAuthority, /* isLocal */ true); + + // Insert another search request in the database. + final SearchTextRequest searchRequest2 = new SearchTextRequest( + /* mimeTypes */ null, + "volcano" + ); + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest2); + + // Get search request ID + final int searchRequestID2 = + SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest2); + assertWithMessage("Search request ID should exist in DB") + .that(searchRequestID2) + .isAtLeast(0); + + // Update cloud resume key and save + final String cloudResumeKey = "CLOUD_RESUME_KEY"; + final String cloudAuthority = "CLOUD_AUTHORITY"; + SearchRequestDatabaseUtil.updateResumeKey(mDatabase, searchRequestID2, cloudResumeKey, + cloudAuthority, /* isLocal */ false); + + final List<Integer> localSyncedSearchRequests = + SearchRequestDatabaseUtil.getSyncedRequestIds( + mDatabase, + /* isLocal */ true + ); + assertWithMessage("Unexpected count of search requests received.") + .that(localSyncedSearchRequests.size()) + .isEqualTo(1); + assertWithMessage("Unexpected search request id.") + .that(localSyncedSearchRequests.get(0)) + .isEqualTo(searchRequestID1); + + final List<Integer> cloudSyncedSearchRequests = + SearchRequestDatabaseUtil.getSyncedRequestIds( + mDatabase, + /* isLocal */ false + ); + assertWithMessage("Unexpected count of search requests received.") + .that(cloudSyncedSearchRequests.size()) + .isEqualTo(1); + assertWithMessage("Unexpected search request id.") + .that(cloudSyncedSearchRequests.get(0)) + .isEqualTo(searchRequestID2); + } + + @Test + public void testClearAllSearchRequests() { + // Insert a search request in the database. + final SearchTextRequest searchRequest1 = new SearchTextRequest( + /* mimeTypes */ null, + "mountains" + ); + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest1); + + // Insert another search request in the database. + final SearchTextRequest searchRequest2 = new SearchTextRequest( + /* mimeTypes */ null, + "volcano" + ); + SearchRequestDatabaseUtil.saveSearchRequest(mDatabase, searchRequest2); + + assertWithMessage("Search request ID should exist in DB") + .that(SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest1)) + .isAtLeast(0); + assertWithMessage("Search request ID should exist in DB") + .that(SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest2)) + .isAtLeast(0); + + final int deletedSearchRequestsCount = + SearchRequestDatabaseUtil.clearAllSearchRequests(mDatabase); + + assertWithMessage("Incorrect search requests were deleted.") + .that(deletedSearchRequestsCount) + .isEqualTo(2); + assertWithMessage("Search request ID should not exist in DB") + .that(SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest1)) + .isEqualTo(-1); + assertWithMessage("Search request ID should not exist in DB") + .that(SearchRequestDatabaseUtil.getSearchRequestID(mDatabase, searchRequest2)) + .isEqualTo(-1); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/v2/sqlite/SearchResultsDatabaseUtilTest.java b/tests/src/com/android/providers/media/photopicker/v2/sqlite/SearchResultsDatabaseUtilTest.java new file mode 100644 index 000000000..0cc04f7ca --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/v2/sqlite/SearchResultsDatabaseUtilTest.java @@ -0,0 +1,1004 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.v2.sqlite; + +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_2; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_ID_3; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.CLOUD_PROVIDER; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.DATE_TAKEN_MS; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.GENERATION_MODIFIED; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.GIF_IMAGE_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.JPEG_IMAGE_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_1; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_2; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_3; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_ID_4; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.LOCAL_PROVIDER; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.MP4_VIDEO_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.PNG_IMAGE_MIME_TYPE; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.STANDARD_MIME_TYPE_EXTENSION; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.assertAddMediaOperation; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getCloudMediaCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getLocalMediaCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.getMediaCursor; +import static com.android.providers.media.photopicker.util.PickerDbTestUtils.toMediaStoreUri; + +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.provider.MediaStore; + +import androidx.test.InstrumentationRegistry; + +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.SearchState; +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; +import com.android.providers.media.photopicker.v2.PickerDataLayerV2; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class SearchResultsDatabaseUtilTest { + @Mock + private PickerSyncController mMockSyncController; + @Mock + private SearchState mSearchState; + private SQLiteDatabase mDatabase; + private Context mContext; + private PickerDbFacade mFacade; + + @Before + public void setUp() { + initMocks(this); + PickerSyncController.setInstance(mMockSyncController); + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + mDatabase = helper.getWritableDatabase(); + mFacade = new PickerDbFacade(mContext, new PickerSyncLockManager(), LOCAL_PROVIDER); + mFacade.setCloudProvider(CLOUD_PROVIDER); + + doReturn(LOCAL_PROVIDER).when(mMockSyncController).getLocalProvider(); + doReturn(CLOUD_PROVIDER).when(mMockSyncController).getCloudProvider(); + doReturn(CLOUD_PROVIDER).when(mMockSyncController).getCloudProviderOrDefault(any()); + doReturn(mFacade).when(mMockSyncController).getDbFacade(); + doReturn(mSearchState).when(mMockSyncController).getSearchState(); + doReturn(true).when(mSearchState).isCloudSearchEnabled(any()); + doReturn(true).when(mSearchState).isCloudSearchEnabled(any(), any()); + doReturn(new PickerSyncLockManager()).when(mMockSyncController).getPickerSyncLockManager(); + } + + @After + public void teardown() { + mDatabase.close(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + } + + @Test + public void testQuerySearchResultsLocalItems() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor1, 1); + final Cursor cursor2 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + final int searchRequestId1 = 1; + + final long cloudRowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1)), + /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(1); + + final long localRowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, LOCAL_PROVIDER, List.of( + getContentValues(LOCAL_ID_1, null, searchRequestId1)), + /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(localRowsInsertedCount) + .isEqualTo(1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(2); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_2); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_1); + } + } + + @Test + public void testQuerySearchResultsCloudItems() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + final int searchRequestId1 = 1; + + // Batch insert items in the search results table. + final long cloudRowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_3, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(3); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + } + } + + @Test + public void testQuerySearchResultsIdFilter() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + final int searchRequestId1 = 1; + final int searchRequestId2 = 2; + final int searchRequestId3 = 3; + + // Batch insert items in the search results table. + final long cloudRowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_3, searchRequestId3), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId2), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId2)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(1); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + } + } + + @Test + public void testQuerySearchResultsSortOrder() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final long dateTaken = 0L; + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, dateTaken + 1); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, dateTaken); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, dateTaken - 1); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, dateTaken); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); + + final int searchRequestId1 = 1; + + // Batch insert items in the search results table. + final long cloudRowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_3, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + final long rowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, LOCAL_PROVIDER, List.of( + getContentValues(LOCAL_ID_4, null, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(rowsInsertedCount) + .isEqualTo(1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(4); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_4); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + } + } + + @Test + public void testQuerySearchResultsPagination() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final long dateTaken = 0L; + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, dateTaken + 1); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, dateTaken); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, dateTaken - 1); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, dateTaken); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); + + final int searchRequestId1 = 1; + + // Batch insert items in the search results table. + final long cloudRowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_3, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + final long rowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, LOCAL_PROVIDER, List.of( + getContentValues(LOCAL_ID_4, null, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(rowsInsertedCount) + .isEqualTo(1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 2); + extras.putLong("picker_id", 2); + extras.putLong("date_taken_millis", dateTaken); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(2); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + } + } + + @Test + public void testQuerySearchResultsMimeTypeFilter() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getMediaCursor(CLOUD_ID_1, DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ null, /* sizeBytes */ 1, MP4_VIDEO_MIME_TYPE, + STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getMediaCursor(CLOUD_ID_2, DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ toMediaStoreUri(LOCAL_ID_2), /* sizeBytes */ 1, + PNG_IMAGE_MIME_TYPE, STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getMediaCursor(CLOUD_ID_3, DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ toMediaStoreUri(LOCAL_ID_3), /* sizeBytes */ 1, + GIF_IMAGE_MIME_TYPE, STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + final Cursor cursor4 = getMediaCursor(LOCAL_ID_4, DATE_TAKEN_MS, GENERATION_MODIFIED, + /* mediaStoreUri */ toMediaStoreUri(LOCAL_ID_4), /* sizeBytes */ 1, + JPEG_IMAGE_MIME_TYPE, STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); + + final int searchRequestId1 = 1; + + // Batch insert items in the search results table. + final long cloudRowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_3, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + final long rowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, LOCAL_PROVIDER, List.of( + getContentValues(LOCAL_ID_4, null, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(rowsInsertedCount) + .isEqualTo(1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + extras.putStringArrayList("mime_types", new ArrayList<>(List.of("video/mp4", "image/gif"))); + + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(2); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + } + } + + @Test + public void testQuerySearchResultsLocalProvidersFilter() { + doReturn(false).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(false).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); + + final int searchRequestId1 = 1; + + // Batch insert items in the search results table. + final long cloudRowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_3, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(cloudRowsInsertedCount) + .isEqualTo(3); + + final long rowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, LOCAL_PROVIDER, List.of( + getContentValues(LOCAL_ID_4, null, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(rowsInsertedCount) + .isEqualTo(1); + + Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(List.of(LOCAL_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(1); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_4); + } + } + + + @Test + public void testQuerySearchResultsCloudProvidersFilter() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor4, 1); + + final int searchRequestId1 = 1; + + // Batch insert items in the search results table. + final long rowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_3, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1), + getContentValues(LOCAL_ID_1, CLOUD_ID_1, searchRequestId1), + getContentValues(LOCAL_ID_4, null, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(rowsInsertedCount) + .isEqualTo(4); + + final Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", new ArrayList<>(List.of(CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(3); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_3); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_2); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + } + } + + + @Test + public void testInsertLocalItems() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor1, 1); + final Cursor cursor2 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor2, 1); + + final int searchRequestId1 = 1; + final int searchRequestId2 = 2; + + // Batch insert items in the search results table. + final long rowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, LOCAL_PROVIDER, List.of( + getContentValues(LOCAL_ID_1, null, searchRequestId1), + getContentValues(LOCAL_ID_1, null, searchRequestId2), + getContentValues(LOCAL_ID_2, null, searchRequestId2), + getContentValues(LOCAL_ID_2, null, searchRequestId2) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(rowsInsertedCount) + .isEqualTo(4); + + final Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(List.of(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + // Query items for searchRequestId1 + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(1); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_1); + } + + // Query items for searchRequestId2 + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId2)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(2); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_2); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_1); + } + } + + @Test + public void testInsertCloudItems() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + final int searchRequestId1 = 1; + final int searchRequestId2 = 2; + + // Batch insert items in the search results table. + final long rowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_1, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId2), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(rowsInsertedCount) + .isEqualTo(3); + + final Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(List.of(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + // Query items for searchRequestId1 + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(2); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_2); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + } + + // Query items for searchRequestId2 + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId2)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(1); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_2); + } + } + + @Test + public void testInsertLocalAndCloudItems() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + final int searchRequestId1 = 1; + + // Batch insert items in the search results table. + long rowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, LOCAL_PROVIDER, List.of( + getContentValues(LOCAL_ID_2, null, searchRequestId1) + ), /* cancellationSignal */ null); + rowsInsertedCount += SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_1, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(rowsInsertedCount) + .isEqualTo(2); + + final Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(List.of(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + // Query items for searchRequestId + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(2); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_2); + + cursor.moveToNext(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(CLOUD_ID_1); + } + } + + @Test + public void testClearObsoleteSearchResults() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getLocalMediaCursor(LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor2, 1); + final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); + + final int searchRequestId1 = 1; + // Batch insert items in the search results table. + long rowsInsertedCount = SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, LOCAL_PROVIDER, List.of( + getContentValues(LOCAL_ID_2, null, searchRequestId1) + ), /* cancellationSignal */ null); + rowsInsertedCount += SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_1, searchRequestId1), + getContentValues(LOCAL_ID_2, CLOUD_ID_2, searchRequestId1) + ), /* cancellationSignal */ null); + + assertWithMessage("Unexpected number of rows inserted in the search results table") + .that(rowsInsertedCount) + .isEqualTo(2); + + final Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(List.of(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + // Query items for searchRequestId + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(2); + } + + // Clear search results received from the cloud provider. + SearchResultsDatabaseUtil.clearObsoleteSearchResults(mDatabase, List.of(searchRequestId1), + /* isLocal */ false); + + // Verify cloud results have been cleared + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(1); + + cursor.moveToFirst(); + assertWithMessage("Media ID is not as expected in the search results") + .that(cursor.getString(cursor.getColumnIndexOrThrow( + PickerSQLConstants.MediaResponse.MEDIA_ID.getProjectedName()))) + .isEqualTo(LOCAL_ID_2); + } + + // Clear search results received from the local provider. + SearchResultsDatabaseUtil.clearObsoleteSearchResults(mDatabase, List.of(searchRequestId1), + /* isLocal */ true); + + // Verify cloud results have been cleared + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(0); + } + } + + @Test + public void testClearAllSearchResults() { + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any()); + doReturn(true).when(mMockSyncController).shouldQueryCloudMedia(any(), any()); + + final Cursor cursor1 = getCloudMediaCursor(CLOUD_ID_1, null, 0); + assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor1, 1); + final Cursor cursor2 = getLocalMediaCursor(LOCAL_ID_1, 0); + assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor2, 1); + + final int searchRequestId1 = 1; + SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, LOCAL_PROVIDER, List.of( + getContentValues(LOCAL_ID_1, null, searchRequestId1) + ), /* cancellationSignal */ null); + SearchResultsDatabaseUtil.cacheSearchResults( + mDatabase, CLOUD_PROVIDER, List.of( + getContentValues(null, CLOUD_ID_1, searchRequestId1) + ), /* cancellationSignal */ null); + + final Bundle extras = new Bundle(); + extras.putInt("page_size", 100); + extras.putStringArrayList("providers", + new ArrayList<>(List.of(LOCAL_PROVIDER, CLOUD_PROVIDER))); + extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); + + // Query items for searchRequestId + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(2); + } + + // Clear all search results + SearchResultsDatabaseUtil.clearAllSearchResults(mDatabase); + + // Query items for searchRequestId + try (Cursor cursor = + PickerDataLayerV2.querySearchMedia(mContext, extras, searchRequestId1)) { + assertWithMessage("Cursor should not be null") + .that(cursor) + .isNotNull(); + + assertWithMessage("Cursor count is not as expected") + .that(cursor.getCount()) + .isEqualTo(0); + } + } + + private ContentValues getContentValues(String localId, String cloudId, int searchRequestId) { + ContentValues contentValues = new ContentValues(); + contentValues.put( + PickerSQLConstants.SearchResultMediaTableColumns.CLOUD_ID.getColumnName(), cloudId); + contentValues.put( + PickerSQLConstants.SearchResultMediaTableColumns.LOCAL_ID.getColumnName(), localId); + contentValues.put( + PickerSQLConstants.SearchResultMediaTableColumns.SEARCH_REQUEST_ID.getColumnName(), + searchRequestId); + return contentValues; + } +} diff --git a/tests/src/com/android/providers/media/photopicker/v2/sqlite/SearchSuggestionsDatabaseUtilTest.java b/tests/src/com/android/providers/media/photopicker/v2/sqlite/SearchSuggestionsDatabaseUtilTest.java new file mode 100644 index 000000000..d38801741 --- /dev/null +++ b/tests/src/com/android/providers/media/photopicker/v2/sqlite/SearchSuggestionsDatabaseUtilTest.java @@ -0,0 +1,908 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopicker.v2.sqlite; + +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_ALBUM; +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_HISTORY; +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_FACE; +import static android.provider.CloudMediaProviderContract.SEARCH_SUGGESTION_LOCATION; + +import static com.android.providers.media.photopicker.v2.sqlite.SearchSuggestionsDatabaseUtils.TTL_CACHED_SUGGESTIONS_IN_DAYS; +import static com.android.providers.media.photopicker.v2.sqlite.SearchSuggestionsDatabaseUtils.TTL_HISTORY_SUGGESTIONS_IN_DAYS; + +import static com.google.common.truth.Truth.assertWithMessage; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.provider.CloudMediaProviderContract; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.providers.media.photopicker.data.PickerDatabaseHelper; +import com.android.providers.media.photopicker.v2.model.SearchRequest; +import com.android.providers.media.photopicker.v2.model.SearchSuggestion; +import com.android.providers.media.photopicker.v2.model.SearchSuggestionRequest; +import com.android.providers.media.photopicker.v2.model.SearchTextRequest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class SearchSuggestionsDatabaseUtilTest { + private SQLiteDatabase mDatabase; + private Context mContext; + + @Before + public void setUp() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + PickerDatabaseHelper helper = new PickerDatabaseHelper(mContext); + mDatabase = helper.getWritableDatabase(); + } + + @After + public void teardown() { + mDatabase.close(); + File dbPath = mContext.getDatabasePath(PickerDatabaseHelper.PICKER_DATABASE_NAME); + dbPath.delete(); + } + + @Test + public void testSaveTextSearchRequestHistory() { + final String searchText = "mountains"; + final SearchTextRequest searchRequest = new SearchTextRequest( + /* mimeTypes */ null, + searchText + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsDatabaseUtils.getHistorySuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of(), + /* limit */ 10)); + + assertWithMessage("Search history suggestions cannot be null") + .that(searchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search history suggestions.") + .that(searchSuggestions.size()) + .isEqualTo(1); + + final SearchSuggestion result = searchSuggestions.get(0); + assertWithMessage("Search history search text is not as expected") + .that(result.getSearchText()) + .isEqualTo(searchText); + assertWithMessage("Search history media set id is not as expected") + .that(result.getMediaSetId()) + .isNull(); + assertWithMessage("Search history authority is not as expected") + .that(result.getAuthority()) + .isNull(); + assertWithMessage("Search history suggestion type is not as expected") + .that(result.getSearchSuggestionType()) + .isEqualTo(SEARCH_SUGGESTION_HISTORY); + } + + @Test + public void testSaveSuggestionSearchRequestHistory() { + final String mediaSetID = "MEDIA-SET-ID"; + final String authority = "com.random.authority"; + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + List.of("video/mp4", "image/*", "image/gif"), + null, + mediaSetID, + authority, + SEARCH_SUGGESTION_LOCATION + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsDatabaseUtils.getHistorySuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("random.authority", authority), + /* limit */ 10)); + + assertWithMessage("Search history suggestions cannot be null") + .that(searchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search history suggestions.") + .that(searchSuggestions.size()) + .isEqualTo(1); + + final SearchSuggestion result = searchSuggestions.get(0); + assertWithMessage("Search history search text is not as expected") + .that(result.getSearchText()) + .isNull(); + assertWithMessage("Search history media set id is not as expected") + .that(result.getMediaSetId()) + .isEqualTo(mediaSetID); + assertWithMessage("Search history authority is not as expected") + .that(result.getAuthority()) + .isEqualTo(authority); + assertWithMessage("Search history suggestion type is not as expected") + .that(result.getSearchSuggestionType()) + .isEqualTo(SEARCH_SUGGESTION_HISTORY); + } + + @Test + public void testQueryHistoryForProviders() { + final String mediaSetID = "MEDIA-SET-ID"; + final String authority = "com.random.authority"; + SearchSuggestionRequest searchRequest = new SearchSuggestionRequest( + List.of("video/mp4", "image/*", "image/gif"), + null, + mediaSetID, + authority, + SEARCH_SUGGESTION_LOCATION + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsDatabaseUtils.getHistorySuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("random.authority", + "another.random.authority"), + /* limit */ 10)); + + assertWithMessage("Search history suggestions cannot be null") + .that(searchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search history suggestions.") + .that(searchSuggestions.size()) + .isEqualTo(0); + } + + @Test + public void testQueryHistorySortOrder() { + final String searchText1 = "mountains"; + final SearchTextRequest searchRequest1 = new SearchTextRequest( + /* mimeTypes */ null, + searchText1 + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest1); + + final String mediaSetId2 = "MEDIA-SET-ID"; + final String authority2 = "com.random.authority"; + SearchSuggestionRequest searchRequest2 = new SearchSuggestionRequest( + List.of("video/mp4", "image/*", "image/gif"), + null, + mediaSetId2, + authority2, + SEARCH_SUGGESTION_LOCATION + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest2); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsDatabaseUtils.getHistorySuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of(authority2), + /* limit */ 10)); + + assertWithMessage("Search history suggestions cannot be null") + .that(searchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search history suggestions.") + .that(searchSuggestions.size()) + .isEqualTo(2); + + final SearchSuggestion firstSuggestion = searchSuggestions.get(0); + assertWithMessage("Search history search text is not as expected") + .that(firstSuggestion.getSearchText()) + .isNull(); + assertWithMessage("Search history media set id is not as expected") + .that(firstSuggestion.getMediaSetId()) + .isEqualTo(mediaSetId2); + assertWithMessage("Search history authority is not as expected") + .that(firstSuggestion.getAuthority()) + .isEqualTo(authority2); + assertWithMessage("Search history suggestion type is not as expected") + .that(firstSuggestion.getSearchSuggestionType()) + .isEqualTo(SEARCH_SUGGESTION_HISTORY); + + final SearchSuggestion secondSuggestion = searchSuggestions.get(1); + assertWithMessage("Search history search text is not as expected") + .that(secondSuggestion.getSearchText()) + .isEqualTo(searchText1); + assertWithMessage("Search history media set id is not as expected") + .that(secondSuggestion.getMediaSetId()) + .isNull(); + assertWithMessage("Search history authority is not as expected") + .that(secondSuggestion.getAuthority()) + .isNull(); + assertWithMessage("Search history suggestion type is not as expected") + .that(secondSuggestion.getSearchSuggestionType()) + .isEqualTo(SEARCH_SUGGESTION_HISTORY); + } + + @Test + public void testQueryHistoryLimit() { + final String searchText1 = "mountains"; + final SearchTextRequest searchRequest1 = new SearchTextRequest( + /* mimeTypes */ null, + searchText1 + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest1); + + final String mediaSetId2 = "MEDIA-SET-ID"; + final String authority2 = "com.random.authority"; + SearchSuggestionRequest searchRequest2 = new SearchSuggestionRequest( + List.of("video/mp4", "image/*", "image/gif"), + null, + mediaSetId2, + authority2, + SEARCH_SUGGESTION_LOCATION + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest2); + + final String searchText3 = "Mormot"; + final SearchTextRequest searchRequest3 = new SearchTextRequest( + /* mimeTypes */ null, + searchText3 + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest3); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsDatabaseUtils.getHistorySuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of(authority2), + /* limit */ 10, + /* historyLimit */ 2, + /* prefix */ "mo")); + + assertWithMessage("Search history suggestions cannot be null") + .that(searchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search history suggestions.") + .that(searchSuggestions.size()) + .isEqualTo(2); + + final SearchSuggestion suggestion1 = searchSuggestions.get(0); + assertWithMessage("Search history search text is not as expected") + .that(suggestion1.getSearchText()) + .isEqualTo(searchText3); + + final SearchSuggestion suggestion2 = searchSuggestions.get(1); + assertWithMessage("Search history search text is not as expected") + .that(suggestion2.getSearchText()) + .isEqualTo(searchText1); + } + + @Test + public void testHistoryPrefixFilter() { + final String searchText1 = "mountains"; + final SearchTextRequest searchRequest1 = new SearchTextRequest( + /* mimeTypes */ null, + searchText1 + ); + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest1); + + final String searchText2 = "beaches"; + SearchSuggestionRequest searchRequest2 = new SearchSuggestionRequest( + List.of("video/mp4", "image/*", "image/gif"), + searchText2, + "mediaSetId", + "authority", + SEARCH_SUGGESTION_LOCATION + ); + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest2); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsDatabaseUtils.getHistorySuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("authority"), + /* limit */ 10, + /* historyLimit */ 3, + /* prefix */ "Beach")); + + assertWithMessage("Search history suggestions cannot be null") + .that(searchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search history suggestions.") + .that(searchSuggestions.size()) + .isEqualTo(1); + + final SearchSuggestion result = searchSuggestions.get(0); + assertWithMessage("Search history search text is not as expected") + .that(result.getSearchText()) + .isEqualTo(searchText2); + } + + @Test + public void testExtractSearchSuggestions() { + final String authority = "authority"; + final SearchSuggestion expectedSearchSuggestion = new SearchSuggestion( + /* searchText */ "mountains", + /* mediaSetId */ "media-set-id", + authority, + SEARCH_SUGGESTION_ALBUM, + /* coverMediaId */ "media-id" + ); + + try (Cursor cursor = getCursor(List.of(expectedSearchSuggestion))) { + final List<SearchSuggestion> result = + SearchSuggestionsDatabaseUtils.extractSearchSuggestions(cursor, authority); + + assertWithMessage("Search suggestions cannot be null") + .that(result) + .isNotNull(); + assertWithMessage("Unexpected number of search suggestions.") + .that(result.size()) + .isEqualTo(1); + assertWithMessage("Search text is not as expected") + .that(result.get(0).getSearchText()) + .isEqualTo(expectedSearchSuggestion.getSearchText()); + assertWithMessage("Media set id is not as expected") + .that(result.get(0).getMediaSetId()) + .isEqualTo(expectedSearchSuggestion.getMediaSetId()); + assertWithMessage("Authority is not as expected") + .that(result.get(0).getAuthority()) + .isEqualTo(expectedSearchSuggestion.getAuthority()); + assertWithMessage("Suggestion type is not as expected") + .that(result.get(0).getSearchSuggestionType()) + .isEqualTo(expectedSearchSuggestion.getSearchSuggestionType()); + } + } + + @Test + public void testSaveSuggestionSearchCache() { + final String mediaSetId1 = "MEDIA-SET-ID-1"; + final String authority1 = "com.random.authority"; + SearchSuggestion searchSuggestion1 = new SearchSuggestion( + /* searchText */ null, + mediaSetId1, + authority1, + SEARCH_SUGGESTION_LOCATION, + /* coverMediaId */ null + ); + + final String mediaSetId2 = "MEDIA-SET-ID-2"; + final String authority2 = "com.another.random.authority"; + SearchSuggestion searchSuggestion2 = new SearchSuggestion( + /* searchText */ null, + mediaSetId2, + authority2, + SEARCH_SUGGESTION_ALBUM, + /* coverMediaId */ null + ); + + final String mediaSetId3 = "MEDIA-SET-ID-3"; + SearchSuggestion searchSuggestion3 = new SearchSuggestion( + /* searchText */ null, + mediaSetId3, + authority2, + SEARCH_SUGGESTION_FACE, + /* coverMediaId */ null + ); + + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions( + mDatabase, authority1, List.of(searchSuggestion1)); + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions( + mDatabase, authority2, List.of(searchSuggestion2, searchSuggestion3)); + + final List<SearchSuggestion> resultSearchSuggestions1 = + SearchSuggestionsDatabaseUtils.getCachedSuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("test", authority1), + /* limit */ 10)); + + assertWithMessage("Search suggestions cannot be null") + .that(resultSearchSuggestions1) + .isNotNull(); + assertWithMessage("Unexpected number of search suggestions.") + .that(resultSearchSuggestions1.size()) + .isEqualTo(1); + + final SearchSuggestion result1 = resultSearchSuggestions1.get(0); + assertWithMessage("Search search text is not as expected") + .that(result1.getSearchText()) + .isNull(); + assertWithMessage("Search media set id is not as expected") + .that(result1.getMediaSetId()) + .isEqualTo(searchSuggestion1.getMediaSetId()); + assertWithMessage("Search authority is not as expected") + .that(result1.getAuthority()) + .isEqualTo(searchSuggestion1.getAuthority()); + assertWithMessage("Search suggestion type is not as expected") + .that(result1.getSearchSuggestionType()) + .isEqualTo(searchSuggestion1.getSearchSuggestionType()); + + final List<SearchSuggestion> resultSearchSuggestions2 = + SearchSuggestionsDatabaseUtils.getCachedSuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("test", authority2), + /* limit */ 10)); + + assertWithMessage("Search suggestions cannot be null") + .that(resultSearchSuggestions2) + .isNotNull(); + assertWithMessage("Unexpected number of search suggestions.") + .that(resultSearchSuggestions2.size()) + .isEqualTo(2); + + final SearchSuggestion result2 = resultSearchSuggestions2.get(0); + assertWithMessage("Search search text is not as expected") + .that(result2.getSearchText()) + .isNull(); + assertWithMessage("Search media set id is not as expected") + .that(result2.getMediaSetId()) + .isEqualTo(searchSuggestion2.getMediaSetId()); + assertWithMessage("Search authority is not as expected") + .that(result2.getAuthority()) + .isEqualTo(searchSuggestion2.getAuthority()); + assertWithMessage("Search suggestion type is not as expected") + .that(result2.getSearchSuggestionType()) + .isEqualTo(searchSuggestion2.getSearchSuggestionType()); + } + + @Test + public void testSuggestionsCachePrefixFilter() { + final String searchText1 = "BUTTER"; + final String mediaSetId1 = "MEDIA-SET-ID-1"; + final String authority = "com.random.authority"; + SearchSuggestion searchSuggestion1 = new SearchSuggestion( + searchText1, + mediaSetId1, + authority, + SEARCH_SUGGESTION_LOCATION, + /* coverMediaId */ null + ); + + final String searchText2 = "Butterfly"; + final String mediaSetId2 = "MEDIA-SET-ID-2"; + SearchSuggestion searchSuggestion2 = new SearchSuggestion( + searchText2, + mediaSetId2, + authority, + SEARCH_SUGGESTION_ALBUM, + /* coverMediaId */ null + ); + + final String searchText3 = "dragon"; + final String mediaSetId3 = "MEDIA-SET-ID-3"; + SearchSuggestion searchSuggestion3 = new SearchSuggestion( + searchText3, + mediaSetId3, + authority, + SEARCH_SUGGESTION_FACE, + /* coverMediaId */ null + ); + + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions(mDatabase, authority, + List.of(searchSuggestion1, searchSuggestion2, searchSuggestion3)); + + final List<SearchSuggestion> resultSearchSuggestions = + SearchSuggestionsDatabaseUtils.getCachedSuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("test", authority), + /* limit */ 10, + /* historyLimit */ 3, + /* prefix */ "but")); + + assertWithMessage("Search suggestions cannot be null") + .that(resultSearchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search suggestions.") + .that(resultSearchSuggestions.size()) + .isEqualTo(2); + + assertWithMessage("Search search text is not as expected") + .that(resultSearchSuggestions.get(0).getSearchText()) + .isEqualTo(searchSuggestion1.getSearchText()); + assertWithMessage("Search search text is not as expected") + .that(resultSearchSuggestions.get(1).getSearchText()) + .isEqualTo(searchSuggestion2.getSearchText()); + } + + @Test + public void testSuggestionsCacheQueryLimit() { + final String searchText1 = "BUTTER"; + final String mediaSetId1 = "MEDIA-SET-ID-1"; + final String authority = "com.random.authority"; + SearchSuggestion searchSuggestion1 = new SearchSuggestion( + searchText1, + mediaSetId1, + authority, + SEARCH_SUGGESTION_LOCATION, + /* coverMediaId */ null + ); + + final String searchText2 = "dragon"; + final String mediaSetId2 = "MEDIA-SET-ID-2"; + SearchSuggestion searchSuggestion2 = new SearchSuggestion( + searchText2, + mediaSetId2, + authority, + SEARCH_SUGGESTION_ALBUM, + /* coverMediaId */ null + ); + + final String searchText3 = "Butterfly"; + final String mediaSetId3 = "MEDIA-SET-ID-3"; + SearchSuggestion searchSuggestion3 = new SearchSuggestion( + searchText3, + mediaSetId3, + authority, + SEARCH_SUGGESTION_FACE, + /* coverMediaId */ null + ); + + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions(mDatabase, authority, + List.of(searchSuggestion1, searchSuggestion2, searchSuggestion3)); + + final List<SearchSuggestion> resultSearchSuggestions = + SearchSuggestionsDatabaseUtils.getCachedSuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("test", authority), + /* limit */ 2, + /* historyLimit */ 3, + /* prefix */ "but")); + + assertWithMessage("Search suggestions cannot be null") + .that(resultSearchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search suggestions.") + .that(resultSearchSuggestions.size()) + .isEqualTo(2); + + assertWithMessage("Search search text is not as expected") + .that(resultSearchSuggestions.get(1).getSearchText()) + .isEqualTo(searchSuggestion3.getSearchText()); + assertWithMessage("Search search text is not as expected") + .that(resultSearchSuggestions.get(0).getSearchText()) + .isEqualTo(searchSuggestion1.getSearchText()); + } + + @Test + public void testSaveSuggestionWithNullMediaSetId() { + final String authority = "com.random.authority"; + SearchSuggestion searchSuggestion = new SearchSuggestion( + /* searchText */ null, + /* mediaSetId */ null, + authority, + SEARCH_SUGGESTION_LOCATION, + /* coverMediaId */ null + ); + + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions( + mDatabase, authority, List.of(searchSuggestion)); + + final List<SearchSuggestion> resultSearchSuggestions = + SearchSuggestionsDatabaseUtils.getCachedSuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("random.authority", authority), + /* limit */ 10)); + + assertWithMessage("Search suggestions cannot be null") + .that(resultSearchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search suggestions.") + .that(resultSearchSuggestions.size()) + .isEqualTo(0); + } + + @Test + public void testCleanUpSearchSuggestionsBeforeCaching() { + final String authority = "com.random.authority"; + SearchSuggestion searchSuggestion1 = new SearchSuggestion( + /* searchText */ null, + "media-set-id", + authority, + SEARCH_SUGGESTION_ALBUM, + /* coverMediaId */ null + ); + + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions( + mDatabase, authority, List.of(searchSuggestion1)); + + final List<SearchSuggestion> resultSearchSuggestions1 = + SearchSuggestionsDatabaseUtils.getCachedSuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("random.authority", authority), + /* limit */ 10)); + + assertWithMessage("Search suggestions cannot be null") + .that(resultSearchSuggestions1) + .isNotNull(); + assertWithMessage("Unexpected number of search suggestions.") + .that(resultSearchSuggestions1.size()) + .isEqualTo(1); + + SearchSuggestion searchSuggestion2 = new SearchSuggestion( + /* searchText */ null, + "media-set-id", + authority, + SEARCH_SUGGESTION_FACE, + /* coverMediaId */ null + ); + + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions( + mDatabase, authority, List.of(searchSuggestion2)); + + final List<SearchSuggestion> resultSearchSuggestions2 = + SearchSuggestionsDatabaseUtils.getCachedSuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of("random.authority", authority), + /* limit */ 10)); + + assertWithMessage("Search suggestions cannot be null") + .that(resultSearchSuggestions2) + .isNotNull(); + assertWithMessage("Unexpected number of search suggestions.") + .that(resultSearchSuggestions2.size()) + .isEqualTo(1); + } + + @Test + public void testClearExpiredCachedSuggestions() { + final String authority = "com.random.authority"; + SearchSuggestion searchSuggestion1 = new SearchSuggestion( + /* searchText */ null, + "media-set-id1", + authority, + SEARCH_SUGGESTION_ALBUM, + /* coverMediaId */ null + ); + SearchSuggestion searchSuggestion2 = new SearchSuggestion( + /* searchText */ "test", + "media-set-id2", + authority, + SEARCH_SUGGESTION_ALBUM, + /* coverMediaId */ null + ); + + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions( + mDatabase, authority, List.of(searchSuggestion1, searchSuggestion2)); + + final ContentValues contentValues = new ContentValues(); + contentValues.put( + PickerSQLConstants.SearchSuggestionsTableColumns.CREATION_TIME_MS.getColumnName(), + System.currentTimeMillis() + - TimeUnit.DAYS.toMillis(TTL_CACHED_SUGGESTIONS_IN_DAYS + 1)); + mDatabase.update( + PickerSQLConstants.Table.SEARCH_SUGGESTION.name(), + contentValues, + PickerSQLConstants.SearchSuggestionsTableColumns.MEDIA_SET_ID.getColumnName() + + " = 'media-set-id2'", null); + + final int rowsDeletedCount = + SearchSuggestionsDatabaseUtils.clearExpiredCachedSearchSuggestions(mDatabase); + assertWithMessage("Unexpected number of expired cached suggestions deleted.") + .that(rowsDeletedCount) + .isEqualTo(1); + } + + @Test + public void testClearExpiredHistorySuggestions() { + SearchRequest searchRequest1 = new SearchTextRequest( + /* mimeTypes */ null, + "summer" + ); + SearchRequest searchRequest2 = new SearchTextRequest( + /* mimeTypes */ null, + "coffee" + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest1); + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest2); + + final ContentValues contentValues = new ContentValues(); + contentValues.put( + PickerSQLConstants.SearchHistoryTableColumns.CREATION_TIME_MS.getColumnName(), + System.currentTimeMillis() + - TimeUnit.DAYS.toMillis(TTL_HISTORY_SUGGESTIONS_IN_DAYS + 1)); + mDatabase.update( + PickerSQLConstants.Table.SEARCH_HISTORY.name(), + contentValues, + PickerSQLConstants.SearchHistoryTableColumns.SEARCH_TEXT.getColumnName() + + " = 'summer'", null); + + final int rowsDeletedCount = + SearchSuggestionsDatabaseUtils.clearExpiredHistorySearchSuggestions(mDatabase); + assertWithMessage("Unexpected number of expired history suggestions deleted.") + .that(rowsDeletedCount) + .isEqualTo(1); + } + + @Test + public void testClearCachedSuggestionsForAuthority() { + final String authority1 = "com.random.authority1"; + final String authority2 = "com.random.authority2"; + SearchSuggestion searchSuggestion1 = new SearchSuggestion( + /* searchText */ null, + "media-set-id1", + authority1, + SEARCH_SUGGESTION_ALBUM, + /* coverMediaId */ null + ); + SearchSuggestion searchSuggestion2 = new SearchSuggestion( + /* searchText */ "test", + "media-set-id2", + authority2, + SEARCH_SUGGESTION_ALBUM, + /* coverMediaId */ null + ); + + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions( + mDatabase, authority1, List.of(searchSuggestion1)); + SearchSuggestionsDatabaseUtils.cacheSearchSuggestions( + mDatabase, authority2, List.of(searchSuggestion2)); + + int rowsDeletedCount = + SearchSuggestionsDatabaseUtils.clearCachedSearchSuggestionsForAuthority( + mDatabase, authority1); + assertWithMessage("Unexpected number of cached suggestions deleted.") + .that(rowsDeletedCount) + .isEqualTo(1); + + rowsDeletedCount = + SearchSuggestionsDatabaseUtils.clearCachedSearchSuggestionsForAuthority( + mDatabase, null); + assertWithMessage("Unexpected number of cached suggestions deleted.") + .that(rowsDeletedCount) + .isEqualTo(1); + } + + @Test + public void testClearHistorySuggestionsForAuthority() { + final String authority1 = "com.random.authority1"; + final String authority2 = "com.random.authority2"; + + SearchRequest searchRequest1 = new SearchTextRequest( + /* mimeTypes */ null, + "summer" + ); + SearchRequest searchRequest2 = new SearchSuggestionRequest( + /* mimeTypes */ null, + /* searchText */ null, + "media-set-id1", + authority1, + SEARCH_SUGGESTION_ALBUM + ); + SearchRequest searchRequest3 = new SearchSuggestionRequest( + /* mimeTypes */ null, + /* searchText */ "test", + "media-set-id2", + authority2, + SEARCH_SUGGESTION_ALBUM + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest1); + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest2); + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest3); + + int rowsDeletedCount = + SearchSuggestionsDatabaseUtils.clearHistorySearchSuggestionsForAuthority( + mDatabase, authority1); + assertWithMessage("Unexpected number of history suggestions deleted.") + .that(rowsDeletedCount) + .isEqualTo(1); + + rowsDeletedCount = + SearchSuggestionsDatabaseUtils.clearHistorySearchSuggestionsForAuthority( + mDatabase, null); + assertWithMessage("Unexpected number of history suggestions deleted.") + .that(rowsDeletedCount) + .isEqualTo(1); + } + + @Test + public void testSaveDuplicateSearchHistorySuggestion() { + final String searchText = "mountains"; + final SearchTextRequest searchRequest = new SearchTextRequest( + /* mimeTypes */ null, + searchText + ); + + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest); + SearchSuggestionsDatabaseUtils.saveSearchHistory(mDatabase, searchRequest); + + final List<SearchSuggestion> searchSuggestions = + SearchSuggestionsDatabaseUtils.getHistorySuggestions( + mDatabase, + getSearchSuggestionQuery( + /* providers */ List.of(), + /* limit */ 10)); + + assertWithMessage("Search history suggestions cannot be null") + .that(searchSuggestions) + .isNotNull(); + assertWithMessage("Unexpected number of search history suggestions.") + .that(searchSuggestions.size()) + .isEqualTo(1); + + final SearchSuggestion result = searchSuggestions.get(0); + assertWithMessage("Search history search text is not as expected") + .that(result.getSearchText()) + .isEqualTo(searchText); + assertWithMessage("Search history media set id is not as expected") + .that(result.getMediaSetId()) + .isNull(); + assertWithMessage("Search history authority is not as expected") + .that(result.getAuthority()) + .isNull(); + assertWithMessage("Search history suggestion type is not as expected") + .that(result.getSearchSuggestionType()) + .isEqualTo(SEARCH_SUGGESTION_HISTORY); + } + + private Cursor getCursor(@NonNull List<SearchSuggestion> searchSuggestions) { + final MatrixCursor cursor = new MatrixCursor( + CloudMediaProviderContract.SearchSuggestionColumns.ALL_PROJECTION); + + for (SearchSuggestion searchSuggestion : searchSuggestions) { + cursor.addRow(List.of( + searchSuggestion.getMediaSetId(), + searchSuggestion.getSearchText(), + searchSuggestion.getSearchSuggestionType(), + searchSuggestion.getCoverMediaId() + ).toArray(new Object[4])); + } + + return cursor; + } + + private SearchSuggestionsQuery getSearchSuggestionQuery(@Nullable List<String> providers, + int limit) { + return getSearchSuggestionQuery(providers, limit, 10, ""); + } + + private SearchSuggestionsQuery getSearchSuggestionQuery(@Nullable List<String> providers, + int limit, + int historyLimit, + @Nullable String prefix) { + final Bundle bundle = new Bundle(); + bundle.putInt("limit", limit); + bundle.putStringArrayList("providers", new ArrayList<>(providers)); + bundle.putInt("history_limit", historyLimit); + bundle.putString("prefix", prefix); + return new SearchSuggestionsQuery(bundle); + } +} diff --git a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelPaginationTest.java b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelPaginationTest.java index 8bb2500c6..432508d54 100644 --- a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelPaginationTest.java +++ b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelPaginationTest.java @@ -129,6 +129,10 @@ public class PickerViewModelPaginationTest { final Context isolatedContext = new IsolatedContext(sTargetContext, /* tag */ "databases", /* asFuseThread */ false, sTargetContext.getUser(), testConfigStore); when(mApplication.getApplicationContext()).thenReturn(isolatedContext); + + final UserIdManager userIdManager = mock(UserIdManager.class); + when(userIdManager.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER); + sInstrumentation.runOnMainSync(() -> { mPickerViewModel = new PickerViewModel(mApplication) { @Override @@ -136,14 +140,13 @@ public class PickerViewModelPaginationTest { setConfigStore(testConfigStore); } }; + mPickerViewModel.initUserManagers(userIdManager); }); if (testConfigStore.isPrivateSpaceInPhotoPickerEnabled() && SdkLevel.isAtLeastS()) { final UserManagerState userManagerState = mock(UserManagerState.class); when(userManagerState.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER); mPickerViewModel.setUserManagerState(userManagerState); } else { - final UserIdManager userIdManager = mock(UserIdManager.class); - when(userIdManager.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER); mPickerViewModel.setUserIdManager(userIdManager); } diff --git a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java index 607dca406..31dc50652 100644 --- a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java +++ b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java @@ -157,6 +157,9 @@ public class PickerViewModelTest { mConfigStore.disablePrivateSpaceInPhotoPicker(); } + final UserIdManager userIdManager = mock(UserIdManager.class); + when(userIdManager.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER); + getInstrumentation().runOnMainSync(() -> { mPickerViewModel = new PickerViewModel(mApplication) { @Override @@ -164,6 +167,7 @@ public class PickerViewModelTest { setConfigStore(mConfigStore); } }; + mPickerViewModel.initUserManagers(userIdManager); }); mItemsProvider = new TestItemsProvider(sTargetContext); mPickerViewModel.setItemsProvider(mItemsProvider); @@ -176,8 +180,6 @@ public class PickerViewModelTest { mBannerManager = BannerTestUtils.getTestCloudBannerManager( sTargetContext, userManagerState, mConfigStore); } else { - final UserIdManager userIdManager = mock(UserIdManager.class); - when(userIdManager.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER); mPickerViewModel.setUserIdManager(userIdManager); mBannerManager = BannerTestUtils.getTestCloudBannerManager( sTargetContext, userIdManager, mConfigStore); diff --git a/tests/src/com/android/providers/media/photopickersearch/CloudMediaProviderSearch.java b/tests/src/com/android/providers/media/photopickersearch/CloudMediaProviderSearch.java new file mode 100644 index 000000000..38f6f8f71 --- /dev/null +++ b/tests/src/com/android/providers/media/photopickersearch/CloudMediaProviderSearch.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopickersearch; + +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.graphics.Point; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.CloudMediaProvider; +import android.provider.CloudMediaProviderContract; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.FileNotFoundException; + +public class CloudMediaProviderSearch extends CloudMediaProvider { + public static final String SEARCH_PROVIDER_FOR_PICKER_CLIENT_AUTHORITY = + "com.android.providers.media.photopicker.tests.cloud_provider_for_search_client"; + public static final String[] MEDIA_PROJECTIONS = new String[] { + CloudMediaProviderContract.MediaColumns.ID + }; + + public static final String TEST_MEDIA_ID_FROM_SUGGESTED_SEARCH = "1"; + public static final String TEST_MEDIA_ID_IN_MEDIA_SET = "2"; + public static final String TEST_MEDIA_ID_FROM_TEXT_SEARCH = "3"; + + public static final String TEST_MEDIA_SET_ID = "11"; + public static final String TEST_MEDIA_CATEGORY_ID = "111"; + public static final String TEST_SEARCH_SUGGESTION_MEDIA_SET_ID = "2"; + + @Override + public Cursor onSearchMedia(String mediaSetId, String fallbackSearchText, + Bundle extras, CancellationSignal cancellationSignal) { + MatrixCursor mockCursor = new MatrixCursor(MEDIA_PROJECTIONS); + mockCursor.addRow(new Object[]{TEST_MEDIA_ID_FROM_SUGGESTED_SEARCH}); + return mockCursor; + } + + @Override + public Cursor onSearchMedia(String searchText, + Bundle extras, CancellationSignal cancellationSignal) { + MatrixCursor mockCursor = new MatrixCursor(MEDIA_PROJECTIONS); + mockCursor.addRow(new Object[]{TEST_MEDIA_ID_FROM_TEXT_SEARCH}); + return mockCursor; + } + + @Override + public Cursor onQueryMediaInMediaSet(String mediaSetId, Bundle extras, + CancellationSignal cancellationSignal) { + MatrixCursor mockCursor = new MatrixCursor(MEDIA_PROJECTIONS); + mockCursor.addRow(new Object[]{TEST_MEDIA_ID_IN_MEDIA_SET}); + return mockCursor; + } + + @Override + public Cursor onQueryMediaSets(String mediaCategoryId, Bundle extras, + CancellationSignal cancellationSignal) { + MatrixCursor mockCursor = new MatrixCursor( + CloudMediaProviderContract.MediaSetColumns.ALL_PROJECTION); + mockCursor.addRow(new Object[]{TEST_MEDIA_SET_ID, "Media Set 1", 1, 25}); + return mockCursor; + } + + @Override + public Cursor onQuerySearchSuggestions(String prefixText, Bundle extras, + CancellationSignal cancellationSignal) { + MatrixCursor mockCursor = new MatrixCursor( + CloudMediaProviderContract.SearchSuggestionColumns.ALL_PROJECTION); + mockCursor.addRow(new Object[]{TEST_SEARCH_SUGGESTION_MEDIA_SET_ID, + 1, "song", "Suggestion 1 for " + prefixText}); + return mockCursor; + } + + @Override + public Cursor onQueryMediaCategories(String parentCategoryId, + Bundle extras, CancellationSignal cancellationSignal) { + MatrixCursor mockCursor = + new MatrixCursor(CloudMediaProviderContract.MediaCategoryColumns.ALL_PROJECTION); + mockCursor.addRow(new Object[]{TEST_MEDIA_CATEGORY_ID, 1, null, 1, 2, 3, 4}); + return mockCursor; + } + + @Override + public Bundle onGetMediaCollectionInfo(@NonNull Bundle extras) { + return new Bundle(); + } + + @Override + public Cursor onQueryMedia(@NonNull Bundle extras) { + return new MatrixCursor(new String[0]); + } + + @Override + public Cursor onQueryDeletedMedia(@NonNull Bundle extras) { + return new MatrixCursor(new String[0]); + } + + @Override + public AssetFileDescriptor onOpenPreview(@NonNull String mediaId, @NonNull Point size, + @Nullable Bundle extras, @Nullable CancellationSignal signal) + throws FileNotFoundException { + throw new UnsupportedOperationException("onOpenPreview not supported"); + } + + @Override + public ParcelFileDescriptor onOpenMedia(@NonNull String mediaId, @Nullable Bundle extras, + @Nullable CancellationSignal signal) throws FileNotFoundException { + throw new UnsupportedOperationException("onOpenMedia not supported"); + } + + @Override + public boolean onCreate() { + return true; + } + +} diff --git a/tests/src/com/android/providers/media/photopickersearch/PickerSearchProviderClientTest.java b/tests/src/com/android/providers/media/photopickersearch/PickerSearchProviderClientTest.java new file mode 100644 index 000000000..60da5d032 --- /dev/null +++ b/tests/src/com/android/providers/media/photopickersearch/PickerSearchProviderClientTest.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2024 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. + */ + +package com.android.providers.media.photopickersearch; + +import static com.android.providers.media.photopickersearch.CloudMediaProviderSearch.MEDIA_PROJECTIONS; +import static com.android.providers.media.photopickersearch.CloudMediaProviderSearch.SEARCH_PROVIDER_FOR_PICKER_CLIENT_AUTHORITY; +import static com.android.providers.media.photopickersearch.CloudMediaProviderSearch.TEST_MEDIA_CATEGORY_ID; +import static com.android.providers.media.photopickersearch.CloudMediaProviderSearch.TEST_MEDIA_ID_FROM_SUGGESTED_SEARCH; +import static com.android.providers.media.photopickersearch.CloudMediaProviderSearch.TEST_MEDIA_ID_FROM_TEXT_SEARCH; +import static com.android.providers.media.photopickersearch.CloudMediaProviderSearch.TEST_MEDIA_ID_IN_MEDIA_SET; +import static com.android.providers.media.photopickersearch.CloudMediaProviderSearch.TEST_MEDIA_SET_ID; +import static com.android.providers.media.photopickersearch.CloudMediaProviderSearch.TEST_SEARCH_SUGGESTION_MEDIA_SET_ID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.database.Cursor; +import android.platform.test.annotations.RequiresFlagsEnabled; +import android.platform.test.flag.junit.CheckFlagsRule; +import android.platform.test.flag.junit.DeviceFlagsValueProvider; +import android.provider.CloudMediaProviderContract; + +import androidx.test.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import com.android.providers.media.flags.Flags; +import com.android.providers.media.photopicker.sync.PickerSearchProviderClient; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RequiresFlagsEnabled(Flags.FLAG_CLOUD_MEDIA_PROVIDER_SEARCH) +@RunWith(AndroidJUnit4.class) +public class PickerSearchProviderClientTest { + + + @Rule + public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); + + private PickerSearchProviderClient mPickerSearchProviderClient; + private Context mContext; + + @Before + public void setUp() { + mContext = InstrumentationRegistry.getTargetContext(); + mPickerSearchProviderClient = + PickerSearchProviderClient.create( + mContext, SEARCH_PROVIDER_FOR_PICKER_CLIENT_AUTHORITY); + } + + @Test + public void testFetchSearchSuggestionsFromCmp() { + Cursor cursor = mPickerSearchProviderClient.fetchSearchSuggestionsFromCmp("test", + 10, null); + cursor.moveToFirst(); + assertEquals(TEST_SEARCH_SUGGESTION_MEDIA_SET_ID, cursor.getString(cursor.getColumnIndex( + CloudMediaProviderContract.SearchSuggestionColumns.MEDIA_SET_ID))); + assertCursorColumns(cursor, + CloudMediaProviderContract.SearchSuggestionColumns.ALL_PROJECTION); + } + + @Test + public void testFetchSuggestedSearchResultsFromCmp() { + Cursor cursor = mPickerSearchProviderClient.fetchSearchResultsFromCmp( + TEST_SEARCH_SUGGESTION_MEDIA_SET_ID, null, 1, null, 100, + null, null); + cursor.moveToFirst(); + assertEquals(TEST_MEDIA_ID_FROM_SUGGESTED_SEARCH, cursor.getString(cursor.getColumnIndex( + CloudMediaProviderContract.MediaColumns.ID))); + assertCursorColumns(cursor, MEDIA_PROJECTIONS); + } + + @Test + public void testFetchTextSearchResultsFromCmp() { + Cursor cursor = mPickerSearchProviderClient.fetchSearchResultsFromCmp( + null, "test", 1, null, 100, + null, null); + cursor.moveToFirst(); + assertEquals(TEST_MEDIA_ID_FROM_TEXT_SEARCH, cursor.getString(cursor.getColumnIndex( + CloudMediaProviderContract.MediaColumns.ID))); + assertCursorColumns(cursor, MEDIA_PROJECTIONS); + } + + @Test + public void testFetchMediasInMediaSetFromCmp() { + Cursor cursor = mPickerSearchProviderClient.fetchMediasInMediaSetFromCmp(TEST_MEDIA_SET_ID, + null, 100, CloudMediaProviderContract.SORT_ORDER_DESC_DATE_TAKEN, + null, null); + cursor.moveToFirst(); + assertEquals(TEST_MEDIA_ID_IN_MEDIA_SET, cursor.getString(cursor.getColumnIndex( + CloudMediaProviderContract.MediaColumns.ID))); + assertCursorColumns(cursor, MEDIA_PROJECTIONS); + } + + + @Test + public void testFetchMediaCategoriesFromCmp() { + Cursor cursor = mPickerSearchProviderClient.fetchMediaCategoriesFromCmp(null, null, null); + cursor.moveToFirst(); + assertEquals(TEST_MEDIA_CATEGORY_ID, cursor.getString( + cursor.getColumnIndex(CloudMediaProviderContract.MediaCategoryColumns.ID))); + assertCursorColumns(cursor, CloudMediaProviderContract.MediaCategoryColumns.ALL_PROJECTION); + } + + @Test + public void testFetchMediaSetsFromCmp() { + Cursor cursor = mPickerSearchProviderClient.fetchMediaSetsFromCmp(TEST_MEDIA_CATEGORY_ID, + null, 10, null, null); + cursor.moveToFirst(); + assertEquals(TEST_MEDIA_SET_ID, cursor.getString( + cursor.getColumnIndex(CloudMediaProviderContract.MediaSetColumns.ID))); + assertCursorColumns(cursor, CloudMediaProviderContract.MediaSetColumns.ALL_PROJECTION); + } + + private static void assertCursorColumns(Cursor cursor, String[] projections) { + for (String columnName : projections) { + assertTrue(cursor.getColumnIndex(columnName) >= 0); + } + } + +} diff --git a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java index b3698d849..a87af175b 100644 --- a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java +++ b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java @@ -30,6 +30,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -49,6 +50,7 @@ import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.os.ParcelFileDescriptor; +import android.os.UserManager; import android.platform.test.annotations.EnableFlags; import android.platform.test.flag.junit.SetFlagsRule; import android.provider.MediaStore; @@ -468,6 +470,11 @@ public class ModernMediaScannerTest { */ @Test public void testVisibleDefaultFolders() throws Exception { + // Skip test if running in Headless System User Mode + // We cannot create nomedia file in public directories for secondary users except + // in [Documents, Downloads]. + assumeFalse(UserManager.isHeadlessSystemUserMode()); + final File root = new File("storage/emulated/0"); assertVisibleFolder(root); @@ -494,6 +501,10 @@ public class ModernMediaScannerTest { */ @Test public void testVisibleRootWithNoMediaDirectory() throws Exception { + // Skip test if running in Headless System User Mode + // We cannot create test files in root directory for secondary users. + assumeFalse(UserManager.isHeadlessSystemUserMode()); + final File root = new File("storage/emulated/0"); final File nomediaDir = new File(root, ".nomedia"); final File file = new File(nomediaDir, "test.jpg"); @@ -949,8 +960,8 @@ public class ModernMediaScannerTest { assertThat(cursor.getCount()).isEqualTo(1); } - // Delete the pending file to make the row is stale - executeShellCommand("rm " + audio.getAbsolutePath()); + // Delete the pending file to make the row is stale. + audio.delete(); assertThat(audio.exists()).isFalse(); // the row still exists @@ -992,7 +1003,7 @@ public class ModernMediaScannerTest { } // Delete the pending file to make the row is stale - executeShellCommand("rm " + audio.getAbsolutePath()); + audio.delete(); assertThat(audio.exists()).isFalse(); // the row still exists @@ -1034,7 +1045,7 @@ public class ModernMediaScannerTest { } // Delete the trashed file to make the row is stale - executeShellCommand("rm " + audio.getAbsolutePath()); + audio.delete(); assertThat(audio.exists()).isFalse(); // the row still exists @@ -1076,7 +1087,7 @@ public class ModernMediaScannerTest { } // Delete the trashed file to make the row is stale - executeShellCommand("rm " + audio.getAbsolutePath()); + audio.delete(); assertThat(audio.exists()).isFalse(); // the row still exists @@ -1113,7 +1124,7 @@ public class ModernMediaScannerTest { } // Delete the file to make the row is stale - executeShellCommand("rm " + audio.getAbsolutePath()); + audio.delete(); assertThat(audio.exists()).isFalse(); // the row still exists diff --git a/tests/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceServiceTest.java b/tests/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceServiceTest.java index 4a6cd851a..9784141ff 100644 --- a/tests/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceServiceTest.java +++ b/tests/src/com/android/providers/media/stableuris/job/StableUriIdleMaintenanceServiceTest.java @@ -282,6 +282,7 @@ public class StableUriIdleMaintenanceServiceTest { .adoptShellPermissionIdentity( Manifest.permission.READ_DEVICE_CONFIG, Manifest.permission.WRITE_DEVICE_CONFIG, + Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG, Manifest.permission.WRITE_MEDIA_STORAGE, android.Manifest.permission.LOG_COMPAT_CHANGE, android.Manifest.permission.READ_COMPAT_CHANGE_CONFIG, diff --git a/tests/src/com/android/providers/media/util/FileUtilsTest.java b/tests/src/com/android/providers/media/util/FileUtilsTest.java index 7c63807e5..637d4be80 100644 --- a/tests/src/com/android/providers/media/util/FileUtilsTest.java +++ b/tests/src/com/android/providers/media/util/FileUtilsTest.java @@ -73,6 +73,7 @@ import static org.junit.Assert.fail; import android.content.ContentValues; import android.os.Environment; import android.os.SystemProperties; +import android.os.UserHandle; import android.provider.MediaStore; import android.provider.MediaStore.Audio.AudioColumns; import android.provider.MediaStore.MediaColumns; @@ -935,8 +936,9 @@ public class FileUtilsTest { // Visibility of default dirs is tested in ModernMediaScannerTest#testVisibleDefaultFolders. @Test public void testIsDirectoryHidden() throws Exception { + int userId = UserHandle.myUserId(); for (String prefix : new String[] { - "/storage/emulated/0", + String.format(Locale.ROOT, "/storage/emulated/%d", userId), "/storage/0000-0000", }) { assertDirectoryNotHidden(new File(prefix)); @@ -945,7 +947,9 @@ public class FileUtilsTest { assertDirectoryHidden(new File(prefix + "/.meow")); } - final File nomediaFile = new File("storage/emulated/0/Download/meow", ".nomedia"); + final File nomediaFile = new File( + String.format(Locale.ROOT, "storage/emulated/%d/Download/meow", userId), + ".nomedia"); try { assertTrue(nomediaFile.getParentFile().mkdirs()); assertTrue(nomediaFile.createNewFile()); @@ -1219,13 +1223,18 @@ public class FileUtilsTest { @Test public void testToAndFromFuseFile() throws Exception { - final File fuseFilePrimary = new File("/mnt/user/0/emulated/0/foo"); - final File fuseFileSecondary = new File("/mnt/user/0/0000-0000/foo"); - - final File lowerFsFilePrimary = new File("/storage/emulated/0/foo"); + int userId = UserHandle.myUserId(); + final File fuseFilePrimary = new File( + String.format(Locale.ROOT, "/mnt/user/%d/emulated/%d/foo", userId, userId)); + final File fuseFileSecondary = new File( + String.format(Locale.ROOT, "/mnt/user/%d/0000-0000/foo", userId)); + + final File lowerFsFilePrimary = new File( + String.format(Locale.ROOT, "/storage/emulated/%d/foo", userId)); final File lowerFsFileSecondary = new File("/storage/0000-0000/foo"); - final File unexpectedFile = new File("/mnt/pass_through/0/emulated/0/foo"); + final File unexpectedFile = new File( + String.format(Locale.ROOT, "/mnt/pass_through/%d/emulated/%d/foo", userId, userId)); assertThat(fromFuseFile(fuseFilePrimary)).isEqualTo(lowerFsFilePrimary); assertThat(fromFuseFile(fuseFileSecondary)).isEqualTo(lowerFsFileSecondary); diff --git a/tests/src/com/android/providers/media/util/MimeTypeFixHandlerTest.java b/tests/src/com/android/providers/media/util/MimeTypeFixHandlerTest.java new file mode 100644 index 000000000..de86f97d7 --- /dev/null +++ b/tests/src/com/android/providers/media/util/MimeTypeFixHandlerTest.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2025 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. + */ + +package com.android.providers.media.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.junit.Assume.assumeTrue; + +import android.content.ClipDescription; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Build; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; +import android.provider.MediaStore; +import android.provider.MediaStore.Files.FileColumns; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.SdkSuppress; +import androidx.test.runner.AndroidJUnit4; + +import com.android.providers.media.flags.Flags; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Objects; +import java.util.Optional; + +@RunWith(AndroidJUnit4.class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.S) +public class MimeTypeFixHandlerTest { + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + private SQLiteDatabase mDatabase; + private static final String FILES_TABLE_NAME = MediaStore.Files.TABLE; + private static final String DATABASE_FILE = "mime_type_fix.db"; + + @Before + public void setUp() throws Exception { + assumeTrue(Build.VERSION.SDK_INT == Build.VERSION_CODES.VANILLA_ICE_CREAM); + final Context context = InstrumentationRegistry.getTargetContext(); + + context.deleteDatabase(DATABASE_FILE); + mDatabase = Objects.requireNonNull( + context.openOrCreateDatabase(DATABASE_FILE, Context.MODE_PRIVATE, null)); + MimeTypeFixHandler.loadMimeTypes(context); + createFilesTable(); + } + + @After + public void tearDown() throws Exception { + final Context context = InstrumentationRegistry.getTargetContext(); + + if (mDatabase != null) { + mDatabase.close(); + } + context.deleteDatabase(DATABASE_FILE); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_MIME_TYPE_FIX_FOR_ANDROID_15) + public void testGetMimeType() { + Optional<String> dwgMimeType = MimeTypeFixHandler.getMimeType("dwg"); + assertTrue(dwgMimeType.isPresent()); + assertEquals(ClipDescription.MIMETYPE_UNKNOWN, dwgMimeType.get()); + + + Optional<String> avifMimeType = MimeTypeFixHandler.getMimeType("avif"); + assertTrue(avifMimeType.isPresent()); + assertEquals("image/avif", avifMimeType.get()); + + + Optional<String> ecmascriptMimeType = MimeTypeFixHandler.getMimeType("es"); + assertTrue(ecmascriptMimeType.isPresent()); + assertEquals("application/ecmascript", ecmascriptMimeType.get()); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_MIME_TYPE_FIX_FOR_ANDROID_15) + public void testUpdateUnsupportedMimeTypesForWrongEntries() { + createEntriesInFilesTable(); + + // Assert incorrect MIME types before the fix + try (Cursor cursor = getCursorFilesTable()) { + assertNotNull(cursor); + + while (cursor.moveToNext()) { + int id = cursor.getInt(cursor.getColumnIndexOrThrow(FileColumns._ID)); + String mimeType = cursor.getString( + cursor.getColumnIndexOrThrow(FileColumns.MIME_TYPE)); + int mediaType = cursor.getInt(cursor.getColumnIndexOrThrow(FileColumns.MEDIA_TYPE)); + + switch (id) { + case 1: // dwg + assertEquals("image/vnd.dwg", mimeType); + assertEquals(FileColumns.MEDIA_TYPE_IMAGE, mediaType); + break; + case 2: // avif + assertEquals("image/avif", mimeType); + assertEquals(FileColumns.MEDIA_TYPE_IMAGE, mediaType); + break; + case 3: // ecmascript + assertEquals("text/javascript", mimeType); + assertEquals(FileColumns.MEDIA_TYPE_DOCUMENT, mediaType); + break; + default: + fail("Unexpected _ID: " + id); + } + } + } + + // fix the data + MimeTypeFixHandler.updateUnsupportedMimeTypes(mDatabase); + + // Assert correct MIME types after the fix + try (Cursor cursor = getCursorFilesTable()) { + assertNotNull(cursor); + + while (cursor.moveToNext()) { + int id = cursor.getInt(cursor.getColumnIndexOrThrow(FileColumns._ID)); + String mimeType = cursor.getString( + cursor.getColumnIndexOrThrow(FileColumns.MIME_TYPE)); + int mediaType = cursor.getInt(cursor.getColumnIndexOrThrow(FileColumns.MEDIA_TYPE)); + + switch (id) { + case 1: // dwg + assertEquals(ClipDescription.MIMETYPE_UNKNOWN, mimeType); + assertEquals(FileColumns.MEDIA_TYPE_NONE, mediaType); + break; + case 2: // avif + assertEquals("image/avif", mimeType); + assertEquals(FileColumns.MEDIA_TYPE_IMAGE, mediaType); + break; + case 3: // ecmascript + assertEquals("application/ecmascript", mimeType); + assertEquals(FileColumns.MEDIA_TYPE_NONE, mediaType); + break; + default: + fail("Unexpected _ID: " + id); + } + } + } + } + + private void createFilesTable() { + mDatabase.execSQL("DROP TABLE IF EXISTS " + FILES_TABLE_NAME + ";"); + mDatabase.execSQL("CREATE TABLE " + FILES_TABLE_NAME + " (" + + FileColumns._ID + " INTEGER PRIMARY KEY, " + + FileColumns.DATA + " TEXT, " + + FileColumns.DISPLAY_NAME + " TEXT, " + + FileColumns.MIME_TYPE + " TEXT, " + + FileColumns.MEDIA_TYPE + " INTEGER);"); + } + + private Cursor getCursorFilesTable() { + String[] projections = new String[]{ + FileColumns._ID, + FileColumns.DATA, + FileColumns.DISPLAY_NAME, + FileColumns.MIME_TYPE, + FileColumns.MEDIA_TYPE, + }; + + return mDatabase.query( + FILES_TABLE_NAME, + projections, + null, + null, + null, + null, + FileColumns._ID + ); + } + + private void createEntriesInFilesTable() { + // dwg in corrupted mime types + String dwgFileName = "image1.dwg"; + insertFileRecord("/path/" + dwgFileName, "image/vnd.dwg", + FileColumns.MEDIA_TYPE_IMAGE, dwgFileName); + + // avif in corrupted mime types but also in android_mime_types + String avifFileName = "image2.avif"; + insertFileRecord("/path/" + avifFileName, "image/avif", + FileColumns.MEDIA_TYPE_IMAGE, avifFileName); + + String ecamascriptFileName = "file1.es"; + insertFileRecord("/path/" + ecamascriptFileName, "text/javascript", + FileColumns.MEDIA_TYPE_DOCUMENT, ecamascriptFileName); + } + + private void insertFileRecord(String data, String mimeType, int mediaType, String displayName) { + String sql = "INSERT INTO " + FILES_TABLE_NAME + " (" + + FileColumns.DATA + ", " + + FileColumns.MIME_TYPE + ", " + + FileColumns.MEDIA_TYPE + ", " + + FileColumns.DISPLAY_NAME + ") " + + "VALUES (?, ?, ?, ?);"; + + mDatabase.execSQL(sql, new Object[]{data, mimeType, mediaType, displayName}); + } +} diff --git a/tests/src/com/android/providers/media/util/PermissionUtilsTest.java b/tests/src/com/android/providers/media/util/PermissionUtilsTest.java index 93def1738..eafc383a4 100644 --- a/tests/src/com/android/providers/media/util/PermissionUtilsTest.java +++ b/tests/src/com/android/providers/media/util/PermissionUtilsTest.java @@ -139,12 +139,18 @@ public class PermissionUtilsTest { assertThat(checkPermissionReadStorage(context, pid, uid, packageName, null)).isTrue(); assertThat(checkPermissionWriteStorage(context, pid, uid, packageName, null)).isTrue(); - assertThat(checkPermissionReadAudio(context, pid, uid, packageName, null, false)).isTrue(); - assertThat(checkPermissionWriteAudio(context, pid, uid, packageName, null)).isFalse(); - assertThat(checkPermissionReadVideo(context, pid, uid, packageName, null, false)).isTrue(); - assertThat(checkPermissionWriteVideo(context, pid, uid, packageName, null)).isFalse(); - assertThat(checkPermissionReadImages(context, pid, uid, packageName, null, false)).isTrue(); - assertThat(checkPermissionWriteImages(context, pid, uid, packageName, null)).isFalse(); + assertThat(checkPermissionReadAudio(context, pid, uid, packageName, null, false, + /* forDataDelivery */ true)).isTrue(); + assertThat(checkPermissionWriteAudio(context, pid, uid, packageName, null, + /* forDataDelivery */ true)).isFalse(); + assertThat(checkPermissionReadVideo(context, pid, uid, packageName, null, false, + /* forDataDelivery */ true)).isTrue(); + assertThat(checkPermissionWriteVideo(context, pid, uid, packageName, null, + /* forDataDelivery */ true)).isFalse(); + assertThat(checkPermissionReadImages(context, pid, uid, packageName, null, false, + /* forDataDelivery */ true)).isTrue(); + assertThat(checkPermissionWriteImages(context, pid, uid, packageName, null, + /* forDataDelivery */ true)).isFalse(); assertThat(checkPermissionInstallPackages(context, pid, uid, packageName, null)).isFalse(); } @@ -291,9 +297,9 @@ public class PermissionUtilsTest { assertMediaReadPermissions(TEST_APP_PID, testAppUid, packageName, true /* targetSdkIsAtLeastT */, false /* expected */); assertThat(checkPermissionReadVisualUserSelected(getContext(), TEST_APP_PID, testAppUid, - packageName, null, false)).isFalse(); + packageName, null, false, /* forDataDelivery */ true)).isFalse(); assertThat(checkPermissionReadVisualUserSelected(getContext(), TEST_APP_PID, testAppUid, - packageName, null, true)).isFalse(); + packageName, null, true, /* forDataDelivery */ true)).isFalse(); } finally { dropShellPermission(); } @@ -462,19 +468,19 @@ public class PermissionUtilsTest { try { assertThat(checkPermissionReadVisualUserSelected(getApplicationContext(), TEST_APP_PID, testAppUid, - packageName, null, targetSdkIsAtLeastT)).isTrue(); + packageName, null, targetSdkIsAtLeastT, /* forDataDelivery */ true)).isTrue(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_VISUAL_USER_SELECTED, AppOpsManager.MODE_ERRORED); assertThat(checkPermissionReadVisualUserSelected(getApplicationContext(), TEST_APP_PID, testAppUid, - packageName, null, targetSdkIsAtLeastT)).isFalse(); + packageName, null, targetSdkIsAtLeastT, /* forDataDelivery */ true)).isFalse(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_VISUAL_USER_SELECTED, AppOpsManager.MODE_ALLOWED); assertThat(checkPermissionReadVisualUserSelected(getApplicationContext(), TEST_APP_PID, testAppUid, - packageName, null, targetSdkIsAtLeastT)).isTrue(); + packageName, null, targetSdkIsAtLeastT, /* forDataDelivery */ true)).isTrue(); } finally { dropShellPermission(); } @@ -487,15 +493,15 @@ public class PermissionUtilsTest { adoptShellPermission(UPDATE_APP_OPS_STATS, MANAGE_APP_OPS_MODES); try { assertThat(checkPermissionReadVideo(getContext(), TEST_APP_PID, testAppUid, - packageName, null, isAtLeastT)).isTrue(); + packageName, null, isAtLeastT, /* forDataDelivery */ true)).isTrue(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_VIDEO, AppOpsManager.MODE_ERRORED); assertThat(checkPermissionReadVideo(getContext(), TEST_APP_PID, testAppUid, - packageName, null, isAtLeastT)).isFalse(); + packageName, null, isAtLeastT, /* forDataDelivery */ true)).isFalse(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_VIDEO, AppOpsManager.MODE_ALLOWED); // Adding sleep before appops check to allow appops change to propagate SystemClock.sleep(200); assertThat(checkPermissionReadVideo(getContext(), TEST_APP_PID, testAppUid, - packageName, null, isAtLeastT)).isTrue(); + packageName, null, isAtLeastT, /* forDataDelivery */ true)).isTrue(); } finally { dropShellPermission(); } @@ -511,17 +517,17 @@ public class PermissionUtilsTest { try { assertThat( checkPermissionWriteAudio(getContext(), TEST_APP_PID, testAppUid, packageName, - null)).isFalse(); + null, /* forDataDelivery */ true)).isFalse(); modifyAppOp(testAppUid, OPSTR_WRITE_MEDIA_AUDIO, AppOpsManager.MODE_ALLOWED); assertThat( checkPermissionWriteAudio(getContext(), TEST_APP_PID, testAppUid, packageName, - null)).isTrue(); + null, /* forDataDelivery */ true)).isTrue(); modifyAppOp(testAppUid, OPSTR_WRITE_MEDIA_AUDIO, AppOpsManager.MODE_ERRORED); assertThat( checkPermissionWriteAudio(getContext(), TEST_APP_PID, testAppUid, packageName, - null)).isFalse(); + null, /* forDataDelivery */ true)).isFalse(); } finally { dropShellPermission(); } @@ -545,17 +551,17 @@ public class PermissionUtilsTest { adoptShellPermission(UPDATE_APP_OPS_STATS, MANAGE_APP_OPS_MODES); try { assertThat(checkPermissionReadAudio(getContext(), TEST_APP_PID, testAppUid, - packageName, null, isAtLeastT)).isTrue(); + packageName, null, isAtLeastT, /* forDataDelivery */ true)).isTrue(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_AUDIO, AppOpsManager.MODE_ERRORED); assertThat(checkPermissionReadAudio(getContext(), TEST_APP_PID, testAppUid, - packageName, null, isAtLeastT)).isFalse(); + packageName, null, isAtLeastT, /* forDataDelivery */ true)).isFalse(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_AUDIO, AppOpsManager.MODE_ALLOWED); // Adding sleep before appops check to allow appops change to propagate SystemClock.sleep(200); assertThat(checkPermissionReadAudio(getContext(), TEST_APP_PID, testAppUid, - packageName, null, isAtLeastT)).isTrue(); + packageName, null, isAtLeastT, /* forDataDelivery */ true)).isTrue(); } finally { dropShellPermission(); } @@ -579,17 +585,17 @@ public class PermissionUtilsTest { adoptShellPermission(UPDATE_APP_OPS_STATS, MANAGE_APP_OPS_MODES); try { assertThat(checkPermissionReadImages(getContext(), TEST_APP_PID, testAppUid, - packageName, null, isAtLeastT)).isTrue(); + packageName, null, isAtLeastT, /* forDataDelivery */ true)).isTrue(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_IMAGES, AppOpsManager.MODE_ERRORED); assertThat(checkPermissionReadImages(getContext(), TEST_APP_PID, testAppUid, - packageName, null, isAtLeastT)).isFalse(); + packageName, null, isAtLeastT, /* forDataDelivery */ true)).isFalse(); modifyAppOp(testAppUid, OPSTR_READ_MEDIA_IMAGES, AppOpsManager.MODE_ALLOWED); // Adding sleep before appops check to allow appops change to propagate SystemClock.sleep(200); assertThat(checkPermissionReadImages(getContext(), TEST_APP_PID, testAppUid, - packageName, null, isAtLeastT)).isTrue(); + packageName, null, isAtLeastT, /* forDataDelivery */ true)).isTrue(); } finally { dropShellPermission(); } @@ -685,11 +691,14 @@ public class PermissionUtilsTest { assertEquals(expected, checkWriteImagesOrVideoAppOps(getContext(), uid, packageName, null)); assertEquals(expected, - checkPermissionWriteImages(getContext(), pid, uid, packageName, null)); + checkPermissionWriteImages(getContext(), pid, uid, packageName, null, + /* forDataDelivery */ true)); assertEquals(expected, - checkPermissionWriteVideo(getContext(), pid, uid, packageName, null)); + checkPermissionWriteVideo(getContext(), pid, uid, packageName, null, + /* forDataDelivery */ true)); assertThat( - checkPermissionWriteAudio(getContext(), pid, uid, packageName, null)) + checkPermissionWriteAudio(getContext(), pid, uid, packageName, null, + /* forDataDelivery */ true)) .isFalse(); } @@ -698,14 +707,17 @@ public class PermissionUtilsTest { assertEquals( expected, checkPermissionReadAudio( - getContext(), pid, uid, packageName, null, targetSdkIsAtLeastT)); + getContext(), pid, uid, packageName, null, targetSdkIsAtLeastT, + /* forDataDelivery */ true)); assertEquals( expected, checkPermissionReadImages( - getContext(), pid, uid, packageName, null, targetSdkIsAtLeastT)); + getContext(), pid, uid, packageName, null, targetSdkIsAtLeastT, + /* forDataDelivery */ true)); assertEquals( expected, checkPermissionReadVideo( - getContext(), pid, uid, packageName, null, targetSdkIsAtLeastT)); + getContext(), pid, uid, packageName, null, targetSdkIsAtLeastT, + /* forDataDelivery */ true)); } } diff --git a/tests/src/com/android/providers/media/util/SyntheticPathUtilsTest.java b/tests/src/com/android/providers/media/util/SyntheticPathUtilsTest.java index 0c8c5df10..dbfafe75b 100644 --- a/tests/src/com/android/providers/media/util/SyntheticPathUtilsTest.java +++ b/tests/src/com/android/providers/media/util/SyntheticPathUtilsTest.java @@ -54,6 +54,8 @@ public class SyntheticPathUtilsTest { ".transforms/synthetic/picker"); assertThat(getPickerRelativePath(PickerUriResolver.PICKER_GET_CONTENT_SEGMENT)).isEqualTo( ".transforms/synthetic/picker_get_content"); + assertThat(getPickerRelativePath(PickerUriResolver.PICKER_TRANSCODED_SEGMENT)).isEqualTo( + ".transforms/synthetic/picker_transcoded"); } @Test @@ -100,6 +102,10 @@ public class SyntheticPathUtilsTest { /* userId */ 0)).isTrue(); assertThat(isPickerPath("/storage/emulated/10/.transforms/synthetic/picker/foo", /* userId */ 10)).isTrue(); + assertThat(isPickerPath("/storage/emulated/0/.transforms/synthetic/picker_transcoded/foo", + /* userId */ 0)).isTrue(); + assertThat(isPickerPath("/storage/emulated/10/.transforms/synthetic/picker_transcoded/foo", + /* userId */ 10)).isTrue(); assertThat(isPickerPath("/storage/emulated/0/.transforms/synthetic/PICKER/bar/baz", /* userId */ 0)).isTrue(); @@ -107,6 +113,10 @@ public class SyntheticPathUtilsTest { /* userId */ 10)).isFalse(); assertThat(isPickerPath("/storage/emulated/10/.transforms/synthetic/picker/foo", /* userId */ 0)).isFalse(); + assertThat(isPickerPath("/storage/emulated/0/.transforms/synthetic/picker_transcoded/foo", + /* userId */ 10)).isFalse(); + assertThat(isPickerPath("/storage/emulated/10/.transforms/synthetic/picker_transcoded/foo", + /* userId */ 0)).isFalse(); assertThat(isPickerPath("/storage/emulated/0/.transforms/synthetic/redacted/foo", /* userId */ 0)).isFalse(); assertThat(isPickerPath("/storage/emulated/0/.transforms/picker/foo", /* userId */ 0)) |