diff options
180 files changed, 5057 insertions, 892 deletions
diff --git a/apex/framework/java/android/provider/CloudMediaProviderContract.java b/apex/framework/java/android/provider/CloudMediaProviderContract.java index b762957a2..e477af289 100644 --- a/apex/framework/java/android/provider/CloudMediaProviderContract.java +++ b/apex/framework/java/android/provider/CloudMediaProviderContract.java @@ -151,6 +151,16 @@ public final class CloudMediaProviderContract { } /** + * @hide + */ + @Override + public String toString() { + return " isSearchEnabled=" + this.mSearchEnabled + + " isMediaCategoriesEnabled=" + this.mMediaCategoriesEnabled + + " isAlbumsAsCategoryEnabled=" + this.mAlbumsAsCategory; + } + + /** * Implemented for {@link Parcelable} */ @Override diff --git a/apex/framework/java/android/provider/MediaStore.java b/apex/framework/java/android/provider/MediaStore.java index 19076f617..3c25ef22b 100644 --- a/apex/framework/java/android/provider/MediaStore.java +++ b/apex/framework/java/android/provider/MediaStore.java @@ -343,6 +343,9 @@ public final class MediaStore { public static final String PICKER_MEDIA_IN_MEDIA_SET_INIT_CALL = "picker_media_in_media_set_init"; /** {@hide} */ + public static final String PICKER_GET_SEARCH_PROVIDERS_CALL = + "picker_internal_get_search_providers"; + /** {@hide} */ public static final String PICKER_TRANSCODE_CALL = "picker_transcode"; /** {@hide} */ public static final String PICKER_TRANSCODE_RESULT = "picker_transcode_result"; diff --git a/pdf/framework-v/api/current.txt b/pdf/framework-v/api/current.txt index ef07ae159..90cf2e276 100644 --- a/pdf/framework-v/api/current.txt +++ b/pdf/framework-v/api/current.txt @@ -37,8 +37,8 @@ package android.graphics.pdf { method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_page_objects") @NonNull public java.util.List<android.util.Pair<java.lang.Integer,android.graphics.pdf.component.PdfPageObject>> getPageObjects(); method @FlaggedApi("android.graphics.pdf.flags.enable_pdf_viewer") @NonNull public java.util.List<android.graphics.pdf.content.PdfPageTextContent> getTextContents(); method @IntRange(from=0) public int getWidth(); - method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_annotations") @NonNull public android.graphics.pdf.component.PdfAnnotation removePageAnnotation(@IntRange(from=0) int); - method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_page_objects") @NonNull public android.graphics.pdf.component.PdfPageObject removePageObject(@IntRange(from=0) int); + method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_annotations") public void removePageAnnotation(@IntRange(from=0) int); + method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_page_objects") public void removePageObject(@IntRange(from=0) int); method public void render(@NonNull android.graphics.Bitmap, @Nullable android.graphics.Rect, @Nullable android.graphics.Matrix, int); method @FlaggedApi("android.graphics.pdf.flags.enable_pdf_viewer") public void render(@NonNull android.graphics.Bitmap, @Nullable android.graphics.Rect, @Nullable android.graphics.Matrix, @NonNull android.graphics.pdf.RenderParams); method @FlaggedApi("android.graphics.pdf.flags.enable_pdf_viewer") @NonNull public java.util.List<android.graphics.pdf.models.PageMatchBounds> searchText(@NonNull String); diff --git a/pdf/framework-v/java/android/graphics/pdf/PdfRenderer.java b/pdf/framework-v/java/android/graphics/pdf/PdfRenderer.java index 2a621279d..6c8a59066 100644 --- a/pdf/framework-v/java/android/graphics/pdf/PdfRenderer.java +++ b/pdf/framework-v/java/android/graphics/pdf/PdfRenderer.java @@ -842,25 +842,17 @@ public final class PdfRenderer implements AutoCloseable { * {@link PdfRenderer#write}. * * @param annotationId id of the annotation to remove from the page - * @return the removed annotation * @throws IllegalArgumentException if annotationId ie negative * @throws IllegalStateException if {@link PdfRenderer} or {@link PdfRenderer.Page} is * closed before invocation or if annotation is failed to * get removed from the page. */ @FlaggedApi(Flags.FLAG_ENABLE_EDIT_PDF_ANNOTATIONS) - @NonNull - public PdfAnnotation removePageAnnotation(@IntRange(from = 0) int annotationId) { + public void removePageAnnotation(@IntRange(from = 0) int annotationId) { throwIfDocumentOrPageClosed(); Preconditions.checkArgument(annotationId >= 0, "Annotation id should be non-negative"); - PdfAnnotation removedAnnotation = mPdfProcessor.removePageAnnotation(mIndex, - annotationId); - if (removedAnnotation == null) { - throw new IllegalStateException( - "Failed to remove annotation with id " + annotationId); - } - return removedAnnotation; + mPdfProcessor.removePageAnnotation(mIndex, annotationId); } /** @@ -976,21 +968,15 @@ public final class PdfRenderer implements AutoCloseable { * {@link PdfRenderer#write}. * * @param objectId the id of the page object to remove from the page. - * @return {@link PdfPageObject} that is removed. * @throws IllegalArgumentException if the provided objectId doesn't exist. * @throws IllegalStateException if the page object cannot be removed. */ @FlaggedApi(Flags.FLAG_ENABLE_EDIT_PDF_PAGE_OBJECTS) - @NonNull - public PdfPageObject removePageObject(@IntRange(from = 0) int objectId) { + public void removePageObject(@IntRange(from = 0) int objectId) { throwIfDocumentOrPageClosed(); Preconditions.checkArgument(objectId >= 0, "Page object id should be greater than equal to 0"); - PdfPageObject pageObject = mPdfProcessor.removePageObject(mIndex, objectId); - if (pageObject == null) { - throw new IllegalStateException("Page object cannot be removed."); - } - return pageObject; + mPdfProcessor.removePageObject(mIndex, objectId); } /** diff --git a/pdf/framework/api/current.txt b/pdf/framework/api/current.txt index c5b0d0d3d..3715cbdef 100644 --- a/pdf/framework/api/current.txt +++ b/pdf/framework/api/current.txt @@ -46,8 +46,8 @@ package android.graphics.pdf { method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_page_objects") @NonNull public java.util.List<android.util.Pair<java.lang.Integer,android.graphics.pdf.component.PdfPageObject>> getPageObjects(); method @NonNull public java.util.List<android.graphics.pdf.content.PdfPageTextContent> getTextContents(); method @IntRange(from=0) public int getWidth(); - method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_annotations") @NonNull public android.graphics.pdf.component.PdfAnnotation removePageAnnotation(@IntRange(from=0) int); - method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_page_objects") @NonNull public android.graphics.pdf.component.PdfPageObject removePageObject(@IntRange(from=0) int); + method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_annotations") public void removePageAnnotation(@IntRange(from=0) int); + method @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_page_objects") public void removePageObject(@IntRange(from=0) int); method public void render(@NonNull android.graphics.Bitmap, @Nullable android.graphics.Rect, @Nullable android.graphics.Matrix, @NonNull android.graphics.pdf.RenderParams); method @NonNull public java.util.List<android.graphics.pdf.models.PageMatchBounds> searchText(@NonNull String); method @Nullable public android.graphics.pdf.models.selection.PageSelection selectContent(@NonNull android.graphics.pdf.models.selection.SelectionBoundary, @NonNull android.graphics.pdf.models.selection.SelectionBoundary); @@ -113,10 +113,8 @@ package android.graphics.pdf.component { } @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_page_objects") public abstract class PdfPageObject { - method @NonNull public android.graphics.RectF getBounds(); method @NonNull public float[] getMatrix(); method public int getPdfObjectType(); - method public void setBounds(@NonNull android.graphics.RectF); method public void setMatrix(@NonNull android.graphics.Matrix); method public void transform(float, float, float, float, float, float); } @@ -130,21 +128,18 @@ package android.graphics.pdf.component { } @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_page_objects") public final class PdfPagePathObject extends android.graphics.pdf.component.PdfPageObject { - ctor public PdfPagePathObject(); + ctor public PdfPagePathObject(@NonNull android.graphics.Path); method @Nullable public android.graphics.Color getFillColor(); - method @Nullable public android.graphics.PathEffect getLineStyle(); - method @NonNull public android.graphics.Path getPath(); - method @NonNull public android.graphics.Color getStrokeColor(); + method @Nullable public android.graphics.Color getStrokeColor(); method public float getStrokeWidth(); method public void setFillColor(@Nullable android.graphics.Color); - method public void setLineStyle(int); - method public void setPath(@NonNull android.graphics.Path); - method public void setStrokeColor(@NonNull android.graphics.Color); + method public void setStrokeColor(@Nullable android.graphics.Color); method public void setStrokeWidth(float); + method @NonNull public android.graphics.Path toPath(); } - @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_page_objects") public final class PdfPageTextObject extends android.graphics.pdf.component.PdfPageObject { - ctor @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_text_objects") public PdfPageTextObject(@NonNull String, @NonNull android.graphics.Typeface, float); + @FlaggedApi("android.graphics.pdf.flags.enable_edit_pdf_text_objects") public final class PdfPageTextObject extends android.graphics.pdf.component.PdfPageObject { + ctor public PdfPageTextObject(@NonNull String, @NonNull android.graphics.Typeface, float); method @Nullable public android.graphics.Color getFillColor(); method public float getFontSize(); method @NonNull public android.graphics.Color getStrokeColor(); diff --git a/pdf/framework/java/android/graphics/pdf/PdfDocumentProxy.java b/pdf/framework/java/android/graphics/pdf/PdfDocumentProxy.java index a66b64b8d..3a1b065a7 100644 --- a/pdf/framework/java/android/graphics/pdf/PdfDocumentProxy.java +++ b/pdf/framework/java/android/graphics/pdf/PdfDocumentProxy.java @@ -31,7 +31,6 @@ import android.graphics.pdf.models.jni.PageSelection; import android.graphics.pdf.models.jni.SelectionBoundary; import android.graphics.pdf.utils.StrictModeUtils; import android.os.ParcelFileDescriptor; -import android.util.Pair; import java.util.List; @@ -260,94 +259,88 @@ public class PdfDocumentProxy { int pageNum, int annotIndex, int[] selectedIndices); /** - * Gets the list of pair of annotations of supported types (freetext, image, stamp) and their - * ids present on the page + * Returns the list of {@link PdfAnnotation} present on the page. + * The list item is non-null for supported types (freetext, image, stamp) and + * null for unsupported types. * * @param pageNum - page number of the page whose annotations list is to be returned + * @return A {@link List} of {@link PdfAnnotation} */ - public native @NonNull List<Pair<Integer, PdfAnnotation>> getPageAnnotations( + public native @NonNull List<PdfAnnotation> getPageAnnotations( @IntRange(from = 0) int pageNum); /** - * Adds an annotation to the given page + * Adds the given {@link PdfAnnotation} to the given page * * @param pageNum - page number of the page to which annotation is to be added - * @param annotation - annotation to be added to the given page - * @return index of the annotation added and -1 in case of failure + * @param annotation - {@link PdfAnnotation} to be added to the given page + * @return index of the annotation added, -1 in case of failure */ public native int addPageAnnotation(@IntRange(from = 0) int pageNum, @NonNull PdfAnnotation annotation); /** - * Removes an annotation from the given page + * Removes the {@link PdfAnnotation} with the specified index from the given page. * - * @param pageNum - page number of the page from which annotation is to be removed - * @param annotationId - id of the annotation to be removed + * @param pageNum - page number from which {@link PdfAnnotation} is to be removed + * @param annotationIndex - index of the {@link PdfAnnotation} to be removed + * + * @return true if remove was successful, false otherwise */ - public native PdfAnnotation removePageAnnotation(@IntRange(from = 0) int pageNum, - @IntRange(from = 0) int annotationId); + public native boolean removePageAnnotation(@IntRange(from = 0) int pageNum, + @IntRange(from = 0) int annotationIndex); /** - * Updates an annotation on the given page + * Update the given {@link PdfAnnotation} on the given page * - * @param pageNum page number of the page on which annotation is to be updated - * @param annotationId id corresponding to which the annotation is to be updated + * @param pageNum page number on which annotation is to be updated + * @param annotationIndex index of the annotation * @param annotation annotation to be updated + * + * @return true if page object is updated, false otherwise */ public native boolean updatePageAnnotation(@IntRange(from = 0) int pageNum, - int annotationId, PdfAnnotation annotation); + int annotationIndex, PdfAnnotation annotation); /** - * Return list of supported {@link PdfPageObject} present on - * the page. - * The list will be empty if there are no supported page - * objects present on the page, even if the page contains - * other page object types. + * Returns the list of {@link PdfPageObject} present on the page. + * The list item is non-null for supported types and + * null for unsupported types. * - * @param pageNum - page number of the page whose annotations list is returned - * @return A {@link List} of {@link Pair} objects, where each pair contains: - * - An {@link Integer} representing the object ID. - * - A {@link PdfPageObject} representing the page object. - * @throws IllegalStateException if the document/page is - * closed before invocation + * @param pageNum - page number of the page whose annotations list is to be returned + * @return A {@link List} of {@link PdfPageObject} */ - public native List<Pair<Integer, PdfPageObject>> getPageObjects(int pageNum); + public native List<PdfPageObject> getPageObjects(int pageNum); /** * Adds the given page object to the page. * * @param pageNum - page number of the page to which pageObject is to be added - * @param pageObject the {@link PdfPageObject} object to - * add - * @return object id of added page object, -1 otherwise - * @throws IllegalArgumentException if the provided {@link PdfPageObject} is unknown or null. - * @throws IllegalStateException if the {@link PdfRenderer.Page} is closed before invocation. + * @param pageObject - {@link PdfPageObject} to be added to the given page + * @return index of added page object, -1 in the case of failure */ public native int addPageObject(int pageNum, @NonNull PdfPageObject pageObject); /** - * Update the given {@link PdfPageObject} to the page. + * Update the given {@link PdfPageObject} on the given page + * + * @param pageNum page number on which the {@link PdfPageObject} is to be updated + * @param objectIndex index of the pageObject + * @param pageObject pageObject to be updated * - * @param objectId The unique identifier of the page object to update. - * @param pageObject the {@code PdfPageObject} object to - * add * @return true if page object is updated, false otherwise - * @throws IllegalArgumentException if the provided {@link PdfPageObject} is unknown or null. - * @throws IllegalStateException if the {@link PdfRenderer.Page} is closed before invocation. */ - public native boolean updatePageObject(int pageNum, int objectId, + public native boolean updatePageObject(int pageNum, int objectIndex, @NonNull PdfPageObject pageObject); /** - * Removes the {@link PdfPageObject} with the specified ID. + * Removes the {@link PdfPageObject} with the specified Index from the given page. + * + * @param pageNum - page number from which {@link PdfPageObject} is to be removed + * @param objectIndex the index of the {@link PdfPageObject} to be removed * - * @param pageNum - page number of the page from which annotation is to be removed - * @param objectId the ID of the page object to remove - * from the page - * @return {@link PdfPageObject} that is removed. - * @throws IllegalStateException if the provided - * objectId doesn't exist. + * @return true if remove was successful, false otherwise */ - public native PdfPageObject removePageObject(int pageNum, int objectId); + public native boolean removePageObject(int pageNum, int objectIndex); } diff --git a/pdf/framework/java/android/graphics/pdf/PdfPageComponentsIdManager.java b/pdf/framework/java/android/graphics/pdf/PdfPageComponentsIdManager.java new file mode 100644 index 000000000..8ecbf7447 --- /dev/null +++ b/pdf/framework/java/android/graphics/pdf/PdfPageComponentsIdManager.java @@ -0,0 +1,68 @@ +/* + * 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 android.graphics.pdf; + +import java.util.HashMap; +import java.util.Map; + +/** + * @hide + * This class manages Id-Index Mapping for PdfAnnotations and PdfPageObjects. + */ +public class PdfPageComponentsIdManager { + private final HashMap<Integer, Integer> mIdIndexMap; + private HashMap<Integer, Integer> mIndexIdMap; + private int mComponentCurrentMaxId; + + public PdfPageComponentsIdManager() { + mIdIndexMap = new HashMap<>(); + mIndexIdMap = new HashMap<>(); + mComponentCurrentMaxId = -1; + } + + void deleteId(int id) { + int deletedComponentIndex = mIdIndexMap.get(id); + mIdIndexMap.remove(id); + + HashMap<Integer, Integer> updatedIndexIdMap = new HashMap<>(); + // Iterate and modify values greater than deletedComponentIndex + for (Map.Entry<Integer, Integer> entry : mIdIndexMap.entrySet()) { + if (entry.getValue() > deletedComponentIndex) { + entry.setValue(entry.getValue() - 1); + } + updatedIndexIdMap.put(entry.getValue(), entry.getKey()); + } + mIndexIdMap = updatedIndexIdMap; + } + + int getIndexForId(int id) { + if (!mIdIndexMap.containsKey(id)) { + return -1; + } + return mIdIndexMap.get(id); + } + + int getIdForIndex(int index) { + if (!mIndexIdMap.containsKey(index)) { + mComponentCurrentMaxId += 1; + mIdIndexMap.put(mComponentCurrentMaxId, index); + mIndexIdMap.put(index, mComponentCurrentMaxId); + return mComponentCurrentMaxId; + } + return mIndexIdMap.get(index); + } +} diff --git a/pdf/framework/java/android/graphics/pdf/PdfProcessor.java b/pdf/framework/java/android/graphics/pdf/PdfProcessor.java index e24465a5b..960d1cffe 100644 --- a/pdf/framework/java/android/graphics/pdf/PdfProcessor.java +++ b/pdf/framework/java/android/graphics/pdf/PdfProcessor.java @@ -53,7 +53,9 @@ import android.util.Pair; import java.io.IOException; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.stream.Collectors; @@ -79,6 +81,8 @@ public class PdfProcessor { private static final Object sPdfiumLock = new Object(); private final PdfEventLogger mPdfEventLogger; private PdfDocumentProxy mPdfDocument; + private final HashMap<Integer, PdfPageComponentsIdManager> mPageObjectIdManagerMap; + private final HashMap<Integer, PdfPageComponentsIdManager> mPageAnnotationsIdManagerMap; public PdfProcessor() { PdfDocumentProxy.loadLibPdf(); @@ -86,6 +90,8 @@ public class PdfProcessor { mPdfEventLogger = new PdfEventLogger( /* processId = */ Binder.getCallingUid(), /* docId = */ new SecureRandom().nextLong()); + mPageObjectIdManagerMap = new HashMap<>(); + mPageAnnotationsIdManagerMap = new HashMap<>(); } /** @@ -371,6 +377,12 @@ public class PdfProcessor { public void retainPage(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); + if (!mPageObjectIdManagerMap.containsKey(pageNum)) { + mPageObjectIdManagerMap.put(pageNum, new PdfPageComponentsIdManager()); + } + if (!mPageAnnotationsIdManagerMap.containsKey(pageNum)) { + mPageAnnotationsIdManagerMap.put(pageNum, new PdfPageComponentsIdManager()); + } mPdfDocument.retainPage(pageNum); } } @@ -379,6 +391,8 @@ public class PdfProcessor { public void releasePage(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); + mPageObjectIdManagerMap.remove(pageNum); + mPageAnnotationsIdManagerMap.remove(pageNum); mPdfDocument.releasePage(pageNum); } } @@ -583,7 +597,18 @@ public class PdfProcessor { public List<Pair<Integer, PdfAnnotation>> getPageAnnotations(@IntRange(from = 0) int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); - return mPdfDocument.getPageAnnotations(pageNum); + PdfPageComponentsIdManager pageAnnotationIdManager = + mPageAnnotationsIdManagerMap.get(pageNum); + List<PdfAnnotation> pdfAnnotations = mPdfDocument.getPageAnnotations(pageNum); + List<Pair<Integer, PdfAnnotation>> pdfAnnotationIdPairs = new ArrayList<>(); + for (int i = 0; i < pdfAnnotations.size(); i++) { + if (pdfAnnotations.get(i) != null) { + pdfAnnotationIdPairs.add( + new Pair<>(pageAnnotationIdManager.getIdForIndex(i), + pdfAnnotations.get(i))); + } + } + return pdfAnnotationIdPairs; } } @@ -603,23 +628,36 @@ public class PdfProcessor { PdfAnnotation annotation) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); - return mPdfDocument.addPageAnnotation(pageNum, annotation); + int addedAnnotationIndex = mPdfDocument.addPageAnnotation(pageNum, annotation); + if (addedAnnotationIndex == -1) { + throw new IllegalArgumentException("Failed to add annotation"); + } + return mPageAnnotationsIdManagerMap.get(pageNum).getIdForIndex(addedAnnotationIndex); } } /** * Removes the annotation with the specified index. * - * @param annotationIndex the index of the annotation to remove + * @param annotationId the Id of the annotation to remove * from the page * @param pageNum page number from which annotation is to be removed - * @return the removed annotation */ - public PdfAnnotation removePageAnnotation(@IntRange(from = 0) int pageNum, - int annotationIndex) { + public void removePageAnnotation(@IntRange(from = 0) int pageNum, + int annotationId) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); - return mPdfDocument.removePageAnnotation(pageNum, annotationIndex); + PdfPageComponentsIdManager pdfAnnotationsIdManager = + mPageAnnotationsIdManagerMap.get(pageNum); + int annotationIndex = pdfAnnotationsIdManager.getIndexForId(annotationId); + if (annotationIndex == -1) { + throw new IllegalArgumentException("Unknown annotationId. getPageAnnotations() " + + "call never made?"); + } + if (!mPdfDocument.removePageAnnotation(pageNum, annotationIndex)) { + throw new IllegalArgumentException("Annotation cannot be removed."); + } + pdfAnnotationsIdManager.deleteId(annotationId); } } @@ -638,7 +676,16 @@ public class PdfProcessor { @NonNull PdfAnnotation annotation) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); - return mPdfDocument.updatePageAnnotation(pageNum, annotationId, annotation); + int annotationIndex = mPageAnnotationsIdManagerMap.get(pageNum) + .getIndexForId(annotationId); + if (annotationIndex == -1) { + throw new IllegalArgumentException("Unknown annotation Id. getPageAnnotations()" + + " call never made?"); + } + if (!mPdfDocument.updatePageAnnotation(pageNum, annotationIndex, annotation)) { + throw new IllegalArgumentException("Update Failed"); + } + return true; } } @@ -659,7 +706,16 @@ public class PdfProcessor { public List<Pair<Integer, PdfPageObject>> getPageObjects(int pageNum) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); - return mPdfDocument.getPageObjects(pageNum); + PdfPageComponentsIdManager pageObjectIdManager = mPageObjectIdManagerMap.get(pageNum); + List<PdfPageObject> pageObjects = mPdfDocument.getPageObjects(pageNum); + List<Pair<Integer, PdfPageObject>> pageObjectIdPairs = new ArrayList<>(); + for (int i = 0; i < pageObjects.size(); i++) { + if (pageObjects.get(i) != null) { + pageObjectIdPairs.add( + new Pair<>(pageObjectIdManager.getIdForIndex(i), pageObjects.get(i))); + } + } + return pageObjectIdPairs; } } @@ -676,7 +732,11 @@ public class PdfProcessor { public int addPageObject(int pageNum, @NonNull PdfPageObject pageObject) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); - return mPdfDocument.addPageObject(pageNum, pageObject); + int addedObjectIndex = mPdfDocument.addPageObject(pageNum, pageObject); + if (addedObjectIndex == -1) { + throw new IllegalArgumentException("Failed to add PageObject"); + } + return mPageObjectIdManagerMap.get(pageNum).getIdForIndex(addedObjectIndex); } } @@ -694,7 +754,15 @@ public class PdfProcessor { @NonNull PdfPageObject pageObject) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); - return mPdfDocument.updatePageObject(pageNum, objectId, pageObject); + int objectIndex = mPageObjectIdManagerMap.get(pageNum).getIndexForId(objectId); + if (objectIndex == -1) { + throw new IllegalArgumentException("Unknown objectId. " + + "getPageObjects() call never made?"); + } + if (!mPdfDocument.updatePageObject(pageNum, objectIndex, pageObject)) { + throw new IllegalArgumentException("Update Failed"); + } + return true; } } @@ -703,15 +771,24 @@ public class PdfProcessor { * * @param objectId the id of the page object to remove * from the page - * @return {@link PdfPageObject} that is removed. * @throws IllegalStateException if the provided * objectId doesn't exist. */ @FlaggedApi(Flags.FLAG_ENABLE_EDIT_PDF_PAGE_OBJECTS) - public PdfPageObject removePageObject(int pageNum, int objectId) { + public void removePageObject(int pageNum, int objectId) { synchronized (sPdfiumLock) { assertPdfDocumentNotNull(); - return mPdfDocument.removePageObject(pageNum, objectId); + PdfPageComponentsIdManager pageObjectIdManager = + mPageObjectIdManagerMap.get(pageNum); + int objectIndex = pageObjectIdManager.getIndexForId(objectId); + if (objectIndex == -1) { + throw new IllegalArgumentException("Unknown objectId. getPageObjects() " + + "call never made ?"); + } + if (!mPdfDocument.removePageObject(pageNum, objectIndex)) { + throw new IllegalArgumentException("Page object cannot be removed."); + } + pageObjectIdManager.deleteId(objectId); } } diff --git a/pdf/framework/java/android/graphics/pdf/PdfRendererPreV.java b/pdf/framework/java/android/graphics/pdf/PdfRendererPreV.java index 05df63909..d5081ae98 100644 --- a/pdf/framework/java/android/graphics/pdf/PdfRendererPreV.java +++ b/pdf/framework/java/android/graphics/pdf/PdfRendererPreV.java @@ -674,28 +674,19 @@ public final class PdfRendererPreV implements AutoCloseable { * {@link PdfRendererPreV#write}. * * @param annotationId id of the annotation to remove from the page - * @return the removed annotation * @throws IllegalArgumentException if annotationId ie negative * @throws IllegalStateException if {@link PdfRendererPreV} or * {@link PdfRendererPreV.Page} is closed before invocation * or if annotation is failed to get removed from the page. */ @FlaggedApi(Flags.FLAG_ENABLE_EDIT_PDF_ANNOTATIONS) - @NonNull - public PdfAnnotation removePageAnnotation(@IntRange(from = 0) int annotationId) { + public void removePageAnnotation(@IntRange(from = 0) int annotationId) { throwIfDocumentOrPageClosed(); Preconditions.checkArgument(annotationId >= 0, "Annotation id should be non-negative"); - PdfAnnotation removedAnnotation = mPdfProcessor.removePageAnnotation(mIndex, - annotationId); - if (removedAnnotation == null) { - throw new IllegalStateException( - "Failed to remove annotation with id " + annotationId); - } - return removedAnnotation; + mPdfProcessor.removePageAnnotation(mIndex, annotationId); } - /** * Update the given {@link PdfAnnotation} to the page. * @@ -810,21 +801,15 @@ public final class PdfRendererPreV implements AutoCloseable { * {@link PdfRenderer#write}. * * @param objectId the id of the page object to remove from the page. - * @return {@link PdfPageObject} that is removed. * @throws IllegalArgumentException if the provided objectId doesn't exist. * @throws IllegalStateException if the page object cannot be removed. */ @FlaggedApi(Flags.FLAG_ENABLE_EDIT_PDF_PAGE_OBJECTS) - @NonNull - public PdfPageObject removePageObject(@IntRange(from = 0) int objectId) { + public void removePageObject(@IntRange(from = 0) int objectId) { throwIfDocumentOrPageClosed(); Preconditions.checkArgument(objectId >= 0, "Page object id should be greater than equal to 0."); - PdfPageObject pageObject = mPdfProcessor.removePageObject(mIndex, objectId); - if (pageObject == null) { - throw new IllegalStateException("Page object cannot be removed."); - } - return pageObject; + mPdfProcessor.removePageObject(mIndex, objectId); } /** diff --git a/pdf/framework/java/android/graphics/pdf/component/PdfPageObject.java b/pdf/framework/java/android/graphics/pdf/component/PdfPageObject.java index 9eadf701e..890402e50 100644 --- a/pdf/framework/java/android/graphics/pdf/component/PdfPageObject.java +++ b/pdf/framework/java/android/graphics/pdf/component/PdfPageObject.java @@ -19,7 +19,6 @@ package android.graphics.pdf.component; import android.annotation.FlaggedApi; import android.annotation.NonNull; import android.graphics.Matrix; -import android.graphics.RectF; import android.graphics.pdf.flags.Flags; /** @@ -32,9 +31,6 @@ public abstract class PdfPageObject { // Possible Values are {@link PdfPageObjectType} private final int mType; - // Bound of page object - private RectF mBounds; - // Transformation matrix of page object private Matrix mTransform; @@ -58,25 +54,6 @@ public abstract class PdfPageObject { } /** - * Returns the bounding rectangle of the object. - * - * @return The bounding rectangle of the object. - */ - @NonNull - public RectF getBounds() { - return mBounds; - } - - /** - * Sets the bounding rectangle of the object. - * - * @param bounds The bounding rectangle of the object. - */ - public void setBounds(@NonNull RectF bounds) { - this.mBounds = bounds; - } - - /** * Transform the page object * The matrix is composed as: * |a c e| @@ -87,13 +64,6 @@ public abstract class PdfPageObject { Matrix matrix = new Matrix(); matrix.setValues(new float[]{a, e, d, c, b, f, 0, 0, 1}); // Set the matrix values this.mTransform.postConcat(matrix); // Apply the transformation - - // Update the objectRect based on the new transformation - if (this.mBounds != null) { - RectF newRect = new RectF(this.mBounds); - matrix.mapRect(newRect); - this.mBounds.set(newRect); - } } /** diff --git a/pdf/framework/java/android/graphics/pdf/component/PdfPagePathObject.java b/pdf/framework/java/android/graphics/pdf/component/PdfPagePathObject.java index e3d5d8020..08ed9ee3a 100644 --- a/pdf/framework/java/android/graphics/pdf/component/PdfPagePathObject.java +++ b/pdf/framework/java/android/graphics/pdf/component/PdfPagePathObject.java @@ -17,30 +17,20 @@ package android.graphics.pdf.component; import android.annotation.FlaggedApi; -import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.graphics.Color; -import android.graphics.DashPathEffect; -import android.graphics.Matrix; import android.graphics.Path; -import android.graphics.PathEffect; -import android.graphics.RectF; import android.graphics.pdf.flags.Flags; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - /** * Represents a path object on a PDF page. This class extends * {@link PdfPageObject} and provides methods to access and modify the - * path's content, such as its shape, fill color, stroke color, line width, - * and line style. + * path's content, such as its shape, fill color, stroke color and line width. */ @FlaggedApi(Flags.FLAG_ENABLE_EDIT_PDF_PAGE_OBJECTS) public final class PdfPagePathObject extends PdfPageObject { - private Path mPath; - private PathEffect mLineStyle; + private final Path mPath; private Color mStrokeColor; private float mStrokeWidth; private Color mFillColor; @@ -49,41 +39,24 @@ public final class PdfPagePathObject extends PdfPageObject { * Constructor for the PdfPagePathObject. Sets the object type * to {@link PdfPageObjectType#PATH}. */ - public PdfPagePathObject() { + public PdfPagePathObject(@NonNull Path path) { super(PdfPageObjectType.PATH); - this.mPath = new Path(); - this.mStrokeColor = new Color(); // Default is opaque black in the sRGB color space. - this.mStrokeWidth = 1.0f; + this.mPath = path; } /** * Returns the path of the object. + * The returned path object might be an approximation of the one used to + * create the original one if the original object has elements with curvature. + * <p> + * Note: The path is immutable because the underlying library does + * not allow modifying the path once it is created. * * @return The path. */ @NonNull - public Path getPath() { - return mPath; - } - - /** - * Sets the path of the object. - * - * @param path The path to set. - */ - public void setPath(@NonNull Path path) { - this.mPath = path; - } - - /** - * Returns the line style of the object's stroke. - * - * @return The {@link PathEffect} representing the line style, or null if no - * style is set. - */ - @Nullable - public PathEffect getLineStyle() { - return mLineStyle; + public Path toPath() { + return new Path(mPath); } /** @@ -91,7 +64,7 @@ public final class PdfPagePathObject extends PdfPageObject { * * @return The stroke color of the object. */ - @NonNull + @Nullable public Color getStrokeColor() { return mStrokeColor; } @@ -101,7 +74,7 @@ public final class PdfPagePathObject extends PdfPageObject { * * @param strokeColor The stroke color of the object. */ - public void setStrokeColor(@NonNull Color strokeColor) { + public void setStrokeColor(@Nullable Color strokeColor) { this.mStrokeColor = strokeColor; } @@ -124,25 +97,6 @@ public final class PdfPagePathObject extends PdfPageObject { } /** - * Sets the line style of the object's stroke. - * - * @param lineStyle An integer representing the line style to set. - */ - public void setLineStyle(int lineStyle) { - switch (lineStyle) { - case LineStyle.DASHED: // Example: Dashed line - this.mLineStyle = new DashPathEffect(new float[]{10, 5}, 0); - break; - case LineStyle.DOTTED: // Example: Dotted line - this.mLineStyle = new DashPathEffect(new float[]{2, 2}, 0); - break; - default: // Solid line (no effect) - this.mLineStyle = null; - break; - } - } - - /** * Returns the fill color of the object. * * @return The fill color of the object. @@ -161,42 +115,4 @@ public final class PdfPagePathObject extends PdfPageObject { this.mFillColor = fillColor; } - /** - * Overrides the - * {@link PdfPageObject#transform(float, float, float, float, float, float)} - * method to correctly transform the Path object. - * - * This method applies the given affine transformation matrix to the path and - * also updates the object's bounding rectangle. - * - * @param a The a value of the transformation matrix. - * @param b The b value of the transformation matrix. - * @param c The c value of the transformation matrix. - * @param d The d value of the transformation matrix. - * @param e The e value of the transformation matrix. - * @param f The f value of the transformation matrix. - */ - @Override - public void transform(float a, float b, float c, float d, float e, float f) { - Matrix matrix = new Matrix(); - matrix.setValues(new float[]{a, c, e, b, d, f, 0, 0, 1}); - this.mPath.transform(matrix); - - // Also transform the objectRect - RectF newRect = new RectF(this.getBounds()); - matrix.mapRect(newRect); - this.getBounds().set(newRect); - } - - /** @hide */ - @IntDef({LineStyle.SOLID, LineStyle.DASHED, LineStyle.DOTTED}) - @Retention(RetentionPolicy.SOURCE) - public @interface LineStyle { - /** Solid line (no effect). */ - int SOLID = 0; - /** Dashed line. */ - int DASHED = 1; - /** Dotted line. */ - int DOTTED = 2; - } } diff --git a/pdf/framework/java/android/graphics/pdf/component/PdfPageTextObject.java b/pdf/framework/java/android/graphics/pdf/component/PdfPageTextObject.java index c004af2a6..aa311fd5d 100644 --- a/pdf/framework/java/android/graphics/pdf/component/PdfPageTextObject.java +++ b/pdf/framework/java/android/graphics/pdf/component/PdfPageTextObject.java @@ -27,7 +27,7 @@ import android.graphics.pdf.flags.Flags; * Represents a text object on a PDF page. * This class extends PageObject and provides methods to access and modify the text content. */ -@FlaggedApi(Flags.FLAG_ENABLE_EDIT_PDF_PAGE_OBJECTS) +@FlaggedApi(Flags.FLAG_ENABLE_EDIT_PDF_TEXT_OBJECTS) public final class PdfPageTextObject extends PdfPageObject { private String mText; private Typeface mTypeface; @@ -43,7 +43,6 @@ public final class PdfPageTextObject extends PdfPageObject { * @param typeface The font of the text. * @param fontSize The font size of the text. */ - @FlaggedApi(Flags.FLAG_ENABLE_EDIT_PDF_TEXT_OBJECTS) public PdfPageTextObject(@NonNull String text, @NonNull Typeface typeface, float fontSize) { super(PdfPageObjectType.TEXT); this.mText = text; diff --git a/pdf/framework/libs/pdfClient/annotation.cc b/pdf/framework/libs/pdfClient/annotation.cc new file mode 100644 index 000000000..63fce4038 --- /dev/null +++ b/pdf/framework/libs/pdfClient/annotation.cc @@ -0,0 +1,163 @@ +/* + * 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. + */ + +#include "annotation.h" + +#include "logging.h" + +#define LOG_TAG "annotation" + +namespace pdfClient { + +std::vector<PageObject*> StampAnnotation::GetObjects() const { + std::vector<PageObject*> page_objects; + for (const auto& page_object : pageObjects_) { + page_objects.push_back(page_object.get()); + } + + return page_objects; +} + +bool StampAnnotation::PopulateFromPdfiumInstance(FPDF_ANNOTATION fpdf_annot) { + int num_of_objects = FPDFAnnot_GetObjectCount(fpdf_annot); + + for (int object_index = 0; object_index < num_of_objects; object_index++) { + FPDF_PAGEOBJECT page_object = FPDFAnnot_GetObject(fpdf_annot, object_index); + int objectType = FPDFPageObj_GetType(page_object); + + std::unique_ptr<PageObject> page_object_; + + switch (objectType) { + case FPDF_PAGEOBJ_PATH: { + page_object_ = std::make_unique<PathObject>(); + break; + } + case FPDF_PAGEOBJ_IMAGE: { + page_object_ = std::make_unique<ImageObject>(); + break; + } + default: { + break; + } + } + + if (page_object_ && !page_object_->PopulateFromFPDFInstance(page_object)) { + LOGE("Failed to get all the data corresponding to object with index " + "%d ", + object_index); + page_object_ = nullptr; + } + + // Add the page_object_ to the stamp annotation even if page_object_ is null + // as we are storing empty unique ptr for the unsupported page objects + AddObject(std::move(page_object_)); + } + return true; +} + +ScopedFPDFAnnotation StampAnnotation::CreatePdfiumInstance(FPDF_DOCUMENT document, FPDF_PAGE page) { + // Create a ScopedFPDFAnnotation, If it will fail to populate this pdfium annot with desired + // params, we will return null that will lead to scoped annot getting out of scope and thus + // getting destroyed + ScopedFPDFAnnotation scoped_annot = + ScopedFPDFAnnotation(FPDFPage_CreateAnnot(page, FPDF_ANNOT_STAMP)); + + if (!scoped_annot) { + LOGE("Failed to create stamp Annotation."); + return nullptr; + } + + Rectangle_f annotation_bounds = GetBounds(); + FS_RECTF rect; + rect.left = annotation_bounds.left; + rect.bottom = annotation_bounds.bottom; + rect.right = annotation_bounds.right; + rect.top = annotation_bounds.top; + + if (!FPDFAnnot_SetRect(scoped_annot.get(), &rect)) { + LOGE("Stamp Annotation bounds can't be set"); + return nullptr; + } + + std::vector<PageObject*> pageObjects = GetObjects(); + for (auto pageObject : pageObjects) { + ScopedFPDFPageObject scoped_page_object = pageObject->CreateFPDFInstance(document); + + if (!scoped_page_object) { + LOGE("Failed to create page object to add in the stamp annotation"); + return nullptr; + } + + if (!FPDFAnnot_AppendObject(scoped_annot.get(), scoped_page_object.release())) { + LOGE("Page object can't be inserted in the stamp annotation"); + return nullptr; + } + } + + return scoped_annot; +} + +bool StampAnnotation::UpdatePdfiumInstance(FPDF_ANNOTATION fpdf_annot, FPDF_DOCUMENT document) { + if (FPDFAnnot_GetSubtype(fpdf_annot) != FPDF_ANNOT_STAMP) { + LOGE("Unsupported operation - can't update a stamp annotation with some other type of " + "annotation"); + return false; + } + + Rectangle_f new_bounds = GetBounds(); + FS_RECTF rect; + rect.left = new_bounds.left; + rect.bottom = new_bounds.bottom; + rect.right = new_bounds.right; + rect.top = new_bounds.top; + if (!FPDFAnnot_SetRect(fpdf_annot, &rect)) { + LOGE("Failed to update the bounds of the stamp annotation at given index"); + return false; + } + + // First Remove all the known existing objects from the stamp annotation, and then rewrite + int num_objects = FPDFAnnot_GetObjectCount(fpdf_annot); + for (int object_index = 0; object_index < num_objects; object_index++) { + FPDF_PAGEOBJECT pageObject = FPDFAnnot_GetObject(fpdf_annot, object_index); + int object_type = FPDFPageObj_GetType(pageObject); + if (pageObject != nullptr && + (object_type == FPDF_PAGEOBJ_IMAGE || object_type == FPDF_PAGEOBJ_PATH)) { + if (!FPDFAnnot_RemoveObject(fpdf_annot, object_index)) { + LOGE("Failed to remove existing object from stamp annotation"); + return false; + } + } + } + + // Rewrite + std::vector<PageObject*> newPageObjects = GetObjects(); + for (auto pageObject : newPageObjects) { + ScopedFPDFPageObject scoped_page_object = pageObject->CreateFPDFInstance(document); + + if (!scoped_page_object) { + LOGE("Failed to create new page object to add in the stamp annotation"); + return false; + } + + if (!FPDFAnnot_AppendObject(fpdf_annot, scoped_page_object.release())) { + LOGE("Page object can't be inserted in the stamp annotation"); + return false; + } + } + return true; +} + +} // namespace pdfClient
\ No newline at end of file diff --git a/pdf/framework/libs/pdfClient/annotation.h b/pdf/framework/libs/pdfClient/annotation.h new file mode 100644 index 000000000..482ff8a37 --- /dev/null +++ b/pdf/framework/libs/pdfClient/annotation.h @@ -0,0 +1,85 @@ +/* + * 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. + */ + +#ifndef MEDIAPROVIDER_PDF_JNI_PDFCLIENT_ANNOTATION_H_ +#define MEDIAPROVIDER_PDF_JNI_PDFCLIENT_ANNOTATION_H_ + +#include <map> + +#include "fpdf_annot.h" +#include "fpdfview.h" +#include "page_object.h" +#include "rect.h" + +using pdfClient::PageObject; +using pdfClient::Rectangle_f; + +namespace pdfClient { +// Base class for different type of annotations +class Annotation { + public: + enum class Type { UNKNOWN = 0, Stamp = 1 }; + + Annotation(Type type, const Rectangle_f& bounds) : type_(type), bounds_(bounds) {} + virtual ~Annotation() = default; + + Type GetType() const { return type_; } + + Rectangle_f GetBounds() const { return bounds_; } + void SetBounds(Rectangle_f bounds) { bounds_ = bounds; } + + virtual bool PopulateFromPdfiumInstance(FPDF_ANNOTATION fpdf_annot) = 0; + virtual ScopedFPDFAnnotation CreatePdfiumInstance(FPDF_DOCUMENT document, FPDF_PAGE page) = 0; + virtual bool UpdatePdfiumInstance(FPDF_ANNOTATION fpdf_annot, FPDF_DOCUMENT document) = 0; + + private: + Type type_; + Rectangle_f bounds_; +}; + +// This class represents a stamp annotation on a page of the pdf document. It doesn't take the +// ownership of the pdfium annotation. It takes the ownership of PdfPageObject inside it but not of +// underlying pdfium page objects +class StampAnnotation : public Annotation { + public: + StampAnnotation(const Rectangle_f& bounds) : Annotation(Type::Stamp, bounds) {} + + // Return a const reference to the list + // Stamp annotation will have the ownership of the page objects inside it + std::vector<PageObject*> GetObjects() const; + + void AddObject(std::unique_ptr<PageObject> pageObject) { + // Take ownership of the PageObject + pageObjects_.push_back(std::move(pageObject)); + } + + void RemoveObject(int index) { + auto it = pageObjects_.begin(); + std::advance(it, index); + pageObjects_.erase(it); + } + + bool PopulateFromPdfiumInstance(FPDF_ANNOTATION fpdf_annot) override; + ScopedFPDFAnnotation CreatePdfiumInstance(FPDF_DOCUMENT document, FPDF_PAGE page) override; + bool UpdatePdfiumInstance(FPDF_ANNOTATION fpdf_annot, FPDF_DOCUMENT document) override; + + private: + std::vector<std::unique_ptr<PageObject>> pageObjects_; +}; + +} // namespace pdfClient + +#endif
\ No newline at end of file diff --git a/pdf/framework/libs/pdfClient/jni_conversion.cc b/pdf/framework/libs/pdfClient/jni_conversion.cc index b77559bb0..a81fd4d1d 100644 --- a/pdf/framework/libs/pdfClient/jni_conversion.cc +++ b/pdf/framework/libs/pdfClient/jni_conversion.cc @@ -16,17 +16,26 @@ #include "jni_conversion.h" +#include <android/bitmap.h> #include <string.h> +#include "logging.h" #include "rect.h" +using pdfClient::Color; using pdfClient::Document; +using pdfClient::ImageObject; using pdfClient::LinuxFileOps; +using pdfClient::Matrix; +using pdfClient::PageObject; +using pdfClient::PathObject; using pdfClient::Rectangle_i; using pdfClient::SelectionBoundary; using std::string; using std::vector; +#define LOG_TAG "jni_conversion" + namespace convert { namespace { @@ -42,7 +51,15 @@ static const char* kChoiceOption = "android/graphics/pdf/models/ListItem"; static const char* kGotoLinkDestination = "android/graphics/pdf/content/PdfPageGotoLinkContent$Destination"; static const char* kGotoLink = "android/graphics/pdf/content/PdfPageGotoLinkContent"; - +static const char* kPageObject = "android/graphics/pdf/component/PdfPageObject"; +static const char* kPathObject = "android/graphics/pdf/component/PdfPagePathObject"; +static const char* kImageObject = "android/graphics/pdf/component/PdfPageImageObject"; + +static const char* kBitmap = "android/graphics/Bitmap"; +static const char* kBitmapConfig = "android/graphics/Bitmap$Config"; +static const char* kColor = "android/graphics/Color"; +static const char* kMatrix = "android/graphics/Matrix"; +static const char* kPath = "android/graphics/Path"; static const char* kRect = "android/graphics/Rect"; static const char* kRectF = "android/graphics/RectF"; static const char* kInteger = "java/lang/Integer"; @@ -117,6 +134,23 @@ jobject ToJavaList(JNIEnv* env, const vector<T>& input, return java_list; } +// Copy a C++ vector to a java ArrayList, using the given function to convert. +template <class T> +jobject ToJavaList(JNIEnv* env, const vector<T*>& input, + jobject (*ToJavaObject)(JNIEnv* env, const T*)) { + static jclass arraylist_class = GetPermClassRef(env, kArrayList); + static jmethodID init = env->GetMethodID(arraylist_class, "<init>", "(I)V"); + static jmethodID add = env->GetMethodID(arraylist_class, "add", funcsig("Z", kObject).c_str()); + + jobject java_list = env->NewObject(arraylist_class, init, input.size()); + for (size_t i = 0; i < input.size(); i++) { + jobject java_object = ToJavaObject(env, input[i]); + env->CallBooleanMethod(java_list, add, java_object); + env->DeleteLocalRef(java_object); + } + return java_list; +} + } // namespace jobject ToJavaPdfDocument(JNIEnv* env, std::unique_ptr<Document> doc) { @@ -324,4 +358,384 @@ jobject ToJavaGotoLinks(JNIEnv* env, const vector<GotoLink>& links) { return ToJavaList(env, links, &ToJavaGotoLink); } +jobject ToJavaBitmap(JNIEnv* env, void* buffer, int width, int height) { + // Find Java Bitmap class + static jclass bitmap_class = GetPermClassRef(env, kBitmap); + + // Get createBitmap method ID + static jmethodID create_bitmap = env->GetStaticMethodID( + bitmap_class, "createBitmap", funcsig(kBitmap, "I", "I", kBitmapConfig).c_str()); + + // Get Bitmap.Config.ARGB_8888 field ID + static jclass bitmap_config_class = GetPermClassRef(env, kBitmapConfig); + static jfieldID argb8888_field = + env->GetStaticFieldID(bitmap_config_class, "ARGB_8888", sig(kBitmapConfig).c_str()); + static jobject argb8888 = + env->NewGlobalRef(env->GetStaticObjectField(bitmap_config_class, argb8888_field)); + + // Create a Java Bitmap object + jobject java_bitmap = + env->CallStaticObjectMethod(bitmap_class, create_bitmap, width, height, argb8888); + + // Lock the Bitmap pixels for copying + void* bitmap_pixels; + if (AndroidBitmap_lockPixels(env, java_bitmap, &bitmap_pixels) < 0) { + return NULL; + } + + // Copy the buffer data into java Bitmap. + std::memcpy(bitmap_pixels, buffer, width * height); // 4 bytes per pixel (ARGB_8888) + + // Unlock the Bitmap pixels + AndroidBitmap_unlockPixels(env, java_bitmap); + + return java_bitmap; +} + +jobject ToJavaColor(JNIEnv* env, Color color) { + // Find Java Color class + static jclass color_class = GetPermClassRef(env, kColor); + + // Get valueOf method ID + static jmethodID value_of = + env->GetStaticMethodID(color_class, "valueOf", funcsig(kColor, "I").c_str()); + + // Get ARGB values from Native Color + uint A = color.a; + uint R = color.r; + uint G = color.g; + uint B = color.b; + + // Make ARGB java color int + int java_color_int = (A & 0xFF) << 24 | (R & 0xFF) << 16 | (G & 0xFF) << 8 | (B & 0xFF); + + // Create a Java Color Object. + jobject java_color = env->CallStaticObjectMethod(color_class, value_of, java_color_int); + + return java_color; +} + +jfloatArray ToJavaFloatArray(JNIEnv* env, const float arr[], size_t length) { + // Create Java float Array. + jfloatArray java_float_array = env->NewFloatArray(length); + + // Copy data from the C++ float Array to the Java float Array + env->SetFloatArrayRegion(java_float_array, 0, length, arr); + + return java_float_array; +} + +jobject ToJavaMatrix(JNIEnv* env, const Matrix matrix) { + // Find Java Matrix class + static jclass matrix_class = GetPermClassRef(env, kMatrix); + // Get the constructor method ID + static jmethodID init = env->GetMethodID(matrix_class, "<init>", funcsig("V").c_str()); + + // Create Java Matrix object. + jobject java_matrix = env->NewObject(matrix_class, init); + + // Create Transform Array. + float transform[9] = {matrix.a, matrix.c, matrix.e, matrix.b, matrix.d, matrix.f, 0, 0, 1}; + + // Convert to Java floatArray. + jfloatArray java_float_array = + ToJavaFloatArray(env, transform, sizeof(transform) / sizeof(transform[0])); + + // Matrix setValues. + static jmethodID set_values = env->GetMethodID(matrix_class, "setValues", "([F)V"); + env->CallVoidMethod(java_matrix, set_values, java_float_array); + + return java_matrix; +} + +jobject ToJavaPath(JNIEnv* env, const std::vector<PathObject::Segment>& segments) { + // Find Java Path class. + static jclass path_class = GetPermClassRef(env, kPath); + // Get the constructor methodID. + static jmethodID init = env->GetMethodID(path_class, "<init>", funcsig("V").c_str()); + + // Create Java Path object. + jobject java_path = env->NewObject(path_class, init); + + // Set Path Segments in Java. + for (auto& segment : segments) { + switch (segment.command) { + case PathObject::Segment::Command::Move: { + static jmethodID move_to = + env->GetMethodID(path_class, "moveTo", funcsig("V", "F", "F").c_str()); + + env->CallVoidMethod(java_path, move_to, segment.x, segment.y); + break; + } + case PathObject::Segment::Command::Line: { + static jmethodID line_to = + env->GetMethodID(path_class, "lineTo", funcsig("V", "F", "F").c_str()); + + env->CallVoidMethod(java_path, line_to, segment.x, segment.y); + break; + } + default: + break; + } + // Check if segment isClosed. + if (segment.is_closed) { + static jmethodID close = env->GetMethodID(path_class, "close", funcsig("V").c_str()); + + env->CallVoidMethod(java_path, close); + } + } + + return java_path; +} + +jobject ToJavaPdfPageObject(JNIEnv* env, const PageObject* page_object) { + // Check for Native Supported Object. + if (!page_object) { + return NULL; + } + + jobject java_page_object = NULL; + + switch (page_object->GetType()) { + case PageObject::Type::Path: { + // Cast to PathObject + const PathObject* path_object = static_cast<const PathObject*>(page_object); + + // Find Java PathObject Class. + static jclass path_object_class = GetPermClassRef(env, kPathObject); + // Get Constructor Id. + static jmethodID init_path = + env->GetMethodID(path_object_class, "<init>", funcsig("V", kPath).c_str()); + + // Create Java Path from Native PathSegments. + jobject java_path = ToJavaPath(env, path_object->segments); + + // Create Java PathObject Instance. + java_page_object = env->NewObject(path_object_class, init_path, java_path); + + // Set Java PathObject FillColor. + if (path_object->is_fill_mode) { + static jmethodID set_fill_color = env->GetMethodID( + path_object_class, "setFillColor", funcsig("V", kColor).c_str()); + + env->CallVoidMethod(java_page_object, set_fill_color, + ToJavaColor(env, path_object->fill_color)); + } + + // Set Java PathObject StrokeColor. + if (path_object->is_stroke) { + static jmethodID set_stroke_color = env->GetMethodID( + path_object_class, "setStrokeColor", funcsig("V", kColor).c_str()); + + env->CallVoidMethod(java_page_object, set_stroke_color, + ToJavaColor(env, path_object->stroke_color)); + } + + // Set Java Stroke Width. + static jmethodID set_stroke_width = + env->GetMethodID(path_object_class, "setStrokeWidth", "(F)V"); + env->CallVoidMethod(java_page_object, set_stroke_width, path_object->stroke_width); + + break; + } + case PageObject::Type::Image: { + // Cast to ImageObject + const ImageObject* image_object = static_cast<const ImageObject*>(page_object); + + // Find Java ImageObject Class. + static jclass image_object_class = GetPermClassRef(env, kImageObject); + // Get Constructor Id. + static jmethodID init_image = + env->GetMethodID(image_object_class, "<init>", funcsig("V", kBitmap).c_str()); + + // Get Bitmap readable buffer from ImageObject Data. + void* buffer = image_object->GetBitmapReadableBuffer(); + + // Create Java Bitmap from Native Bitmap Buffer. + jobject java_bitmap = + ToJavaBitmap(env, buffer, image_object->width, image_object->height); + + // Create Java ImageObject Instance. + java_page_object = env->NewObject(image_object_class, init_image, java_bitmap); + + break; + } + default: + break; + } + + // If no PageObject was created, return null + if (java_page_object == NULL) { + return NULL; + } + + // Find Java PageObject class + static jclass page_object_class = GetPermClassRef(env, kPageObject); + + // Set Java Matrix + static jmethodID set_matrix = + env->GetMethodID(page_object_class, "setMatrix", funcsig("V", kMatrix).c_str()); + env->CallVoidMethod(java_page_object, set_matrix, ToJavaMatrix(env, page_object->matrix)); + + return java_page_object; +} + +jobject ToJavaPdfPageObjects(JNIEnv* env, const vector<PageObject*>& page_objects) { + return ToJavaList(env, page_objects, &ToJavaPdfPageObject); +} + +Color ToNativeColor(JNIEnv* env, jobject java_color) { + // Find Java Color class + static jclass color_class = GetPermClassRef(env, kColor); + + // Get the color as an ARGB integer + jmethodID get_color_int = env->GetMethodID(color_class, "toArgb", funcsig("I").c_str()); + jint java_color_int = env->CallIntMethod(java_color, get_color_int); + + // Decoding RGBA components + unsigned int red = (java_color_int >> 16) & 0xFF; + unsigned int green = (java_color_int >> 8) & 0xFF; + unsigned int blue = java_color_int & 0xFF; + unsigned int alpha = (java_color_int >> 24) & 0xFF; + + return Color(red, green, blue, alpha); +} + +std::unique_ptr<PageObject> ToNativePageObject(JNIEnv* env, jobject java_page_object) { + // Find Java PageObject class and GetType + static jclass page_object_class = GetPermClassRef(env, kPageObject); + static jmethodID get_type = env->GetMethodID(page_object_class, "getPdfObjectType", "()I"); + jint page_object_type = env->CallIntMethod(java_page_object, get_type); + + // Pointer to PageObject + std::unique_ptr<PageObject> page_object = nullptr; + + switch (static_cast<PageObject::Type>(page_object_type)) { + case PageObject::Type::Path: { + // Create PathObject Data Instance. + auto path_object = std::make_unique<PathObject>(); + + // Get Ref to Java PathObject Class. + static jclass path_object_class = GetPermClassRef(env, kPathObject); + + // Get Path from Java PathObject. + static jmethodID to_path = + env->GetMethodID(path_object_class, "toPath", funcsig(kPath).c_str()); + jobject java_path = env->CallObjectMethod(java_page_object, to_path); + + // Find Java Path Class. + static jclass path_class = GetPermClassRef(env, kPath); + + // Get the Approximate Array for the Path. + static jmethodID approximate = env->GetMethodID(path_class, "approximate", "(F)[F"); + jfloatArray java_approximate = + (jfloatArray)env->CallObjectMethod(java_path, approximate); + const jsize size = env->GetArrayLength(java_approximate); + + // Copy Java Array to Native Array + float path_approximate[size]; + env->GetFloatArrayRegion(java_approximate, 0, size, path_approximate); + + // Set PathObject Data PathSegments. + auto& segments = path_object->segments; + for (int i = 0; i < size; i += 3) { + if (i == 0 || path_approximate[i] == path_approximate[i - 3]) { + segments.emplace_back(PathObject::Segment::Command::Move, + path_approximate[i + 1], path_approximate[i + 2]); + } else { + segments.emplace_back(PathObject::Segment::Command::Line, + path_approximate[i + 1], path_approximate[i + 2]); + } + } + + // Get Java PathObject Fill Color. + static jmethodID get_fill_color = + env->GetMethodID(path_object_class, "getFillColor", funcsig(kColor).c_str()); + jobject java_fill_color = env->CallObjectMethod(java_page_object, get_fill_color); + + // Set PathObject Data Fill Mode and Fill Color + path_object->is_fill_mode = (java_fill_color != NULL); + if (path_object->is_fill_mode) { + path_object->fill_color = ToNativeColor(env, java_fill_color); + } + + // Get Java PathObject Stroke Color. + static jmethodID get_stroke_color = + env->GetMethodID(path_object_class, "getStrokeColor", funcsig(kColor).c_str()); + jobject java_stroke_color = env->CallObjectMethod(java_page_object, get_stroke_color); + + // Set PathObject Data Stroke Mode and Stroke Color. + path_object->is_stroke = (java_stroke_color != NULL); + if (path_object->is_stroke) { + path_object->stroke_color = ToNativeColor(env, java_stroke_color); + } + + // Get Java PathObject Stroke Width. + static jmethodID get_stroke_width = + env->GetMethodID(path_object_class, "getStrokeWidth", funcsig("F").c_str()); + jfloat stroke_width = env->CallFloatMethod(java_page_object, get_stroke_width); + + // Set PathObject Data Stroke Width. + path_object->stroke_width = stroke_width; + + page_object = std::move(path_object); + break; + } + case PageObject::Type::Image: { + // Create ImageObject Data Instance. + auto image_object = std::make_unique<ImageObject>(); + + // Get Ref to Java ImageObject Class. + static jclass image_object_class = GetPermClassRef(env, kImageObject); + + // Get the bitmap from the Java ImageObject + static jmethodID get_bitmap = + env->GetMethodID(image_object_class, "getBitmap", funcsig(kBitmap).c_str()); + jobject java_bitmap = env->CallObjectMethod(java_page_object, get_bitmap); + + // Create an FPDF_BITMAP from the Android Bitmap + void* bitmap_pixels; + if (AndroidBitmap_lockPixels(env, java_bitmap, &bitmap_pixels) < 0) { + break; + } + + AndroidBitmapInfo bitmap_info; + AndroidBitmap_getInfo(env, java_bitmap, &bitmap_info); + const int stride = bitmap_info.width * 4; + + // Set ImageObject Data Bitmap + image_object->bitmap = ScopedFPDFBitmap(FPDFBitmap_CreateEx( + bitmap_info.width, bitmap_info.height, FPDFBitmap_BGRA, bitmap_pixels, stride)); + + // Unlock the Android Bitmap + AndroidBitmap_unlockPixels(env, java_bitmap); + + page_object = std::move(image_object); + break; + } + default: + break; + } + + if (!page_object) { + return nullptr; + } + + // Get Matrix from Java PageObject. + static jmethodID get_matrix = env->GetMethodID(page_object_class, "getMatrix", "()[F"); + jfloatArray java_matrix_array = + (jfloatArray)env->CallObjectMethod(java_page_object, get_matrix); + + // Copy Java Array to Native Array + float transform[9]; + env->GetFloatArrayRegion(java_matrix_array, 0, 9, transform); + + // Set PageObject Data Matrix. + page_object->matrix = {transform[0 /*kMScaleX*/], transform[3 /*kMSkewY*/], + transform[1 /*kMSkewX*/], transform[4 /*kMScaleY*/], + transform[2 /*kMTransX*/], transform[5 /*kMTransY*/]}; + + return page_object; +} + } // namespace convert
\ No newline at end of file diff --git a/pdf/framework/libs/pdfClient/jni_conversion.h b/pdf/framework/libs/pdfClient/jni_conversion.h index 51d8a8b57..b85aaaed2 100644 --- a/pdf/framework/libs/pdfClient/jni_conversion.h +++ b/pdf/framework/libs/pdfClient/jni_conversion.h @@ -25,13 +25,18 @@ #include "file.h" #include "form_widget_info.h" #include "page.h" +#include "page_object.h" #include "rect.h" +using pdfClient::Color; using pdfClient::Document; using pdfClient::FormWidgetInfo; using pdfClient::GotoLink; using pdfClient::GotoLinkDest; +using pdfClient::Matrix; using pdfClient::Option; +using pdfClient::PageObject; +using pdfClient::PathObject; using pdfClient::Rectangle_i; using pdfClient::SelectionBoundary; using pdfClient::Status; @@ -108,6 +113,24 @@ jobject ToJavaGotoLink(JNIEnv* env, const GotoLink link); jobject ToJavaGotoLinks(JNIEnv* env, const vector<GotoLink>& links); +jobject ToJavaBitmap(JNIEnv* env, void* buffer, int width, int height); + +jobject ToJavaColor(JNIEnv* env, Color color); + +jfloatArray ToJavaFloatArray(JNIEnv* env, const float arr[], size_t length); + +jobject ToJavaMatrix(JNIEnv* env, const Matrix matrix); + +jobject ToJavaPath(JNIEnv* env, const std::vector<PathObject::Segment>& segments); + +jobject ToJavaPdfPageObject(JNIEnv* env, const PageObject* page_object); + +jobject ToJavaPdfPageObjects(JNIEnv* env, const vector<PageObject*>& page_objects); + +Color ToNativeColor(JNIEnv* env, jobject java_color); + +std::unique_ptr<PageObject> ToNativePageObject(JNIEnv* env, jobject java_page_object); + } // namespace convert #endif // MEDIAPROVIDER_PDF_JNI_CONVERSION_H_
\ No newline at end of file diff --git a/pdf/framework/libs/pdfClient/page.cc b/pdf/framework/libs/pdfClient/page.cc index 7765f01b4..e02047dca 100644 --- a/pdf/framework/libs/pdfClient/page.cc +++ b/pdf/framework/libs/pdfClient/page.cc @@ -41,6 +41,7 @@ #define LOG_TAG "page" +using pdfClient::Rectangle_f; using std::vector; namespace pdfClient { @@ -445,38 +446,13 @@ std::vector<PageObject*> Page::GetPageObjects(bool refetch) { int Page::AddPageObject(std::unique_ptr<PageObject> pageObject) { // Create a scoped PDFium page object. - ScopedFPDFPageObject scoped_page_object; - - // Handle different page object types. - switch (pageObject->GetType()) { - case PageObject::Type::Image: { - ImageObject* imgObject = pageObject->AsImage(); - - // Create a PDFium image object. - scoped_page_object = ScopedFPDFPageObject(FPDFPageObj_NewImageObj(document_)); - if (!scoped_page_object) { - return -1; - } - // Set the bitmap for the image object. - if (!FPDFImageObj_SetBitmap(nullptr, 0, scoped_page_object.get(), imgObject->bitmap)) { - return -1; - } - break; - } - default: - break; - } + ScopedFPDFPageObject scoped_page_object = pageObject->CreateFPDFInstance(document_); // Check if a FPDF page object was created. if (!scoped_page_object) { return -1; } - // Set the matrix for the page object. - if (!FPDFPageObj_SetMatrix(scoped_page_object.get(), &pageObject->matrix)) { - return -1; - } - // Insert the FPDF page object into the FPDF page. FPDFPage_InsertObject(page_.get(), scoped_page_object.release()); FPDFPage_GenerateContent(page_.get()); @@ -508,32 +484,16 @@ bool Page::RemovePageObject(int index) { } bool Page::UpdatePageObject(int index, std::unique_ptr<PageObject> pageObject) { + // Check for valid index if (index < 0 || index >= FPDFPage_CountObjects(page_.get())) { return false; } + // Get PDFium PageObject. FPDF_PAGEOBJECT page_object = FPDFPage_GetObject(page_.get(), index); - switch (pageObject->GetType()) { - case PageObject::Type::Image: { - ImageObject* imageObject = pageObject->AsImage(); - - // Check for Type Correctness. - if (FPDFPageObj_GetType(page_object) != FPDF_PAGEOBJ_IMAGE) { - return false; - } - // Set the new bitmap. - if (!FPDFImageObj_SetBitmap(nullptr, 0, page_object, imageObject->bitmap)) { - return false; - } - break; - } - default: - break; - } - - // Set the updated matrix. - if (!FPDFPageObj_SetMatrix(page_object, &pageObject->matrix)) { + // Update PDFium PageObject + if (!pageObject->UpdateFPDFInstance(page_object)) { return false; } @@ -829,37 +789,145 @@ void Page::PopulatePageObjects(bool refetch) { std::unique_ptr<PageObject> page_object_ = nullptr; switch (type) { + case FPDF_PAGEOBJ_PATH: { + page_object_ = std::make_unique<PathObject>(); + break; + } case FPDF_PAGEOBJ_IMAGE: { - auto image_object_ = std::make_unique<ImageObject>(); - // Get Bitmap from Image - FPDF_BITMAP bitmap = FPDFImageObj_GetBitmap(page_object); - if (bitmap) { - image_object_->bitmap = bitmap; - page_object_ = std::move(image_object_); - } + page_object_ = std::make_unique<ImageObject>(); break; } default: break; } - if (page_object_) { - // Get Matrix Data - FPDFPageObj_GetMatrix(page_object, &page_object_->matrix); - // Get Fill Color Data - FPDFPageObj_GetFillColor(page_object, &page_object_->fill_color.r, - &page_object_->fill_color.g, &page_object_->fill_color.b, - &page_object_->fill_color.a); - // Get Stroke Color Data - FPDFPageObj_GetStrokeColor(page_object, &page_object_->stroke_color.r, - &page_object_->stroke_color.g, &page_object_->stroke_color.b, - &page_object_->stroke_color.a); - // Get Stroke Width Data - FPDFPageObj_GetStrokeWidth(page_object, &page_object_->stroke_width); - + // Populate PageObject From Page + if (page_object_ && page_object_->PopulateFromFPDFInstance(page_object)) { page_objects_[index] = std::move(page_object_); } } } +std::vector<Annotation*> Page::GetPageAnnotations() { + PopulateAnnotations(); + + std::vector<Annotation*> result; + + result.reserve(annotations_.size()); + for (const auto& annotation : annotations_) { + result.push_back(annotation.get()); + } + + return result; +} + +void Page::PopulateAnnotations() { + // If page_ is null + if (!page_) { + LOGE("Page is null"); + return; + } + + int num_of_annotations = FPDFPage_GetAnnotCount(page_.get()); + annotations_.resize(num_of_annotations); + + for (int annotation_index = 0; annotation_index < num_of_annotations; annotation_index++) { + ScopedFPDFAnnotation scoped_annot(FPDFPage_GetAnnot(page_.get(), annotation_index)); + int annotationType = FPDFAnnot_GetSubtype(scoped_annot.get()); + + FS_RECTF rect; + if (!FPDFAnnot_GetRect(scoped_annot.get(), &rect)) { + LOGE("Failed to get the bounds of the stamp annotation"); + break; + } + Rectangle_f bounds = Rectangle_f{rect.left, rect.top, rect.right, rect.bottom}; + + std::unique_ptr<Annotation> annotation = nullptr; + + switch (annotationType) { + case FPDF_ANNOT_STAMP: { + annotation = std::make_unique<StampAnnotation>(bounds); + break; + } + default: { + break; + } + } + + if (!annotation || !annotation->PopulateFromPdfiumInstance(scoped_annot.get())) { + LOGE("Failed to create a pdfClient's instance of stamp annotation using pdfium " + "instance"); + } + + annotations_[annotation_index] = std::move(annotation); + } +} + +int Page::AddPageAnnotation(std::unique_ptr<Annotation> annotation) { + ScopedFPDFAnnotation scoped_annot = annotation->CreatePdfiumInstance(document_, page_.get()); + + if (!scoped_annot) { + LOGE("Failed to add the given annotation to the page"); + return -1; + } + + FPDFPage_GenerateContent(page_.get()); + + // Add the object to the annotations_ list + annotations_.push_back(std::move(annotation)); + + // Return the index of added annotation + return FPDFPage_GetAnnotIndex(page_.get(), scoped_annot.get()); +} + +bool Page::RemovePageAnnotation(int index) { + PopulateAnnotations(); + if (index >= annotations_.size() || index < 0) { + LOGE("Given index is out range for number of annotations on this page"); + return false; + } + // Remove the annotation at given index + if (!FPDFPage_RemoveAnnot(page_.get(), index)) { + LOGE("Failed to remove the annotation at index - %d ", index); + return false; + } + + FPDFPage_GenerateContent(page_.get()); + + // Remove from annotations_ list + annotations_.erase(annotations_.begin() + index); + + return true; +} + +bool Page::UpdatePageAnnotation(int index, std::unique_ptr<Annotation> annotation) { + PopulateAnnotations(); + // Check for valid index + if (index < 0 || index >= annotations_.size()) { + return false; + } + + // check if there in an annotation of supported type at given index + if (annotations_[index] == nullptr) { + return false; + } + + // Get the pdfium annotation + ScopedFPDFAnnotation scoped_annot = ScopedFPDFAnnotation(FPDFPage_GetAnnot(page_.get(), index)); + + if (!scoped_annot) { + LOGE("Failed to get pdfium instance"); + return false; + } + + if (annotation->UpdatePdfiumInstance(scoped_annot.get(), document_)) { + LOGE("Failed to update pdfium annotation"); + return false; + } + + FPDFPage_GenerateContent(page_.get()); + + return true; +} + } // namespace pdfClient
\ No newline at end of file diff --git a/pdf/framework/libs/pdfClient/page.h b/pdf/framework/libs/pdfClient/page.h index 884a1df3c..93c885ebe 100644 --- a/pdf/framework/libs/pdfClient/page.h +++ b/pdf/framework/libs/pdfClient/page.h @@ -26,6 +26,7 @@ #include <utility> #include <vector> +#include "annotation.h" #include "cpp/fpdf_scopers.h" #include "form_filler.h" #include "form_widget_info.h" @@ -241,6 +242,20 @@ class Page { // the Page, we only modify the PageObject's attributes. bool UpdatePageObject(int index, std::unique_ptr<PageObject> page_object); + // Get all supported annotations. The list will contain null for the types of annotations + // which are not supported. Page will have ownership of annotations + std::vector<Annotation*> GetPageAnnotations(); + + // Add an annotation to the page + int AddPageAnnotation(std::unique_ptr<Annotation> annotation); + + // Remove the annotation from the page at a given index + bool RemovePageAnnotation(int index); + + // Update the attributes of the annotation on the Page. Ownership stays with + // the Page, we only modify the Annotation's attributes. + bool UpdatePageAnnotation(int index, std::unique_ptr<Annotation> annotation); + private: // Convenience methods to access the variables dependent on an initialized // ScopedFPDFTextPage. We lazy init text_page_ for efficiency because many @@ -352,6 +367,11 @@ class Page { // Populates page_objects_ with PageObjects on Page. void PopulatePageObjects(bool refetch); + + // Annotations + std::vector<std::unique_ptr<Annotation>> annotations_; + + void PopulateAnnotations(); }; } // namespace pdfClient diff --git a/pdf/framework/libs/pdfClient/page_object.cc b/pdf/framework/libs/pdfClient/page_object.cc new file mode 100644 index 000000000..de2183b9b --- /dev/null +++ b/pdf/framework/libs/pdfClient/page_object.cc @@ -0,0 +1,245 @@ +/* + * 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. + */ + +#include "page_object.h" + +#include <stddef.h> +#include <stdint.h> + +#include "cpp/fpdf_scopers.h" +#include "fpdf_edit.h" +#include "fpdfview.h" +#include "logging.h" + +#define LOG_TAG "page_object" + +namespace pdfClient { + +PageObject::PageObject(Type type) : type(type) {} + +PageObject::Type PageObject::GetType() const { + return type; +} + +PageObject::~PageObject() = default; + +PathObject::PathObject() : PageObject(Type::Path) {} + +ScopedFPDFPageObject PathObject::CreateFPDFInstance(FPDF_DOCUMENT document) { + int segment_count = segments.size(); + if (segment_count == 0) { + return nullptr; + } + // Get Start Points + float x = segments[0].x; + float y = segments[0].y; + + // Create a scoped PDFium path object. + ScopedFPDFPageObject scoped_path_object(FPDFPageObj_CreateNewPath(x, y)); + if (!scoped_path_object) { + return nullptr; + } + + // Insert all segments into PDFium Path Object + for (int i = 1; i < segment_count; ++i) { + // Get EndPoint for current segment. + x = segments[i].x; + y = segments[i].y; + switch (segments[i].command) { + case Segment::Command::Move: { + FPDFPath_MoveTo(scoped_path_object.get(), x, y); + break; + } + case Segment::Command::Line: { + FPDFPath_LineTo(scoped_path_object.get(), x, y); + break; + } + default: + break; + } + if (segments[i].is_closed) { + FPDFPath_Close(scoped_path_object.get()); + } + } + + // Update attributes of PDFium path object. + if (!UpdateFPDFInstance(scoped_path_object.get())) { + return nullptr; + } + + return scoped_path_object; +} + +bool PathObject::UpdateFPDFInstance(FPDF_PAGEOBJECT path_object) { + if (!path_object) { + return false; + } + + // Check for Type Correctness. + if (FPDFPageObj_GetType(path_object) != FPDF_PAGEOBJ_PATH) { + return false; + } + + // Set the updated Draw Mode + int fill_mode = this->is_fill_mode ? FPDF_FILLMODE_WINDING : FPDF_FILLMODE_NONE; + if (!FPDFPath_SetDrawMode(path_object, fill_mode, is_stroke)) { + return false; + } + + // Set the updated matrix. + if (!FPDFPageObj_SetMatrix(path_object, reinterpret_cast<FS_MATRIX*>(&matrix))) { + return false; + } + + // Set the updated Stroke Width + FPDFPageObj_SetStrokeWidth(path_object, stroke_width); + // Set the updated Stroke Color + FPDFPageObj_SetStrokeColor(path_object, stroke_color.r, stroke_color.g, stroke_color.b, + stroke_color.a); + // Set the updated Fill Color + FPDFPageObj_SetFillColor(path_object, fill_color.r, fill_color.g, fill_color.b, fill_color.a); + + return true; +} + +bool PathObject::PopulateFromFPDFInstance(FPDF_PAGEOBJECT path_object) { + // Count the number of Segments in the Path + int segment_count = FPDFPath_CountSegments(path_object); + if (segment_count == 0) { + return false; + } + + // Get Path Segments + for (int index = 0; index < segment_count; ++index) { + FPDF_PATHSEGMENT path_segment = FPDFPath_GetPathSegment(path_object, index); + + // Get Type for the current Path Segment + int type = FPDFPathSegment_GetType(path_segment); + if (type == FPDF_SEGMENT_UNKNOWN || type == FPDF_SEGMENT_BEZIERTO) { + // Control point extraction of bezier curve is not supported by Pdfium as of now. + return false; + } + + // Get EndPoint for the current Path Segment + float x, y; + FPDFPathSegment_GetPoint(path_segment, &x, &y); + + // Get Close for the current Path Segment + bool is_closed = FPDFPathSegment_GetClose(path_segment); + + // Add Segment to PageObject Data + if (type == FPDF_SEGMENT_LINETO) { + segments.emplace_back(Segment::Command::Line, x, y, is_closed); + } else { + segments.emplace_back(Segment::Command::Move, x, y, is_closed); + } + } + + // Get Draw Mode + int fill_mode; + FPDF_BOOL stroke; + if (!FPDFPath_GetDrawMode(path_object, &fill_mode, &stroke)) { + LOGE("Path GetDrawMode Failed!"); + return false; + } + this->is_fill_mode = fill_mode; + this->is_stroke = stroke; + + // Get Matrix + if (!FPDFPageObj_GetMatrix(path_object, reinterpret_cast<FS_MATRIX*>(&matrix))) { + return false; + } + + // Get Fill Color + FPDFPageObj_GetFillColor(path_object, &fill_color.r, &fill_color.g, &fill_color.b, + &fill_color.a); + // Get Stroke Color + FPDFPageObj_GetStrokeColor(path_object, &stroke_color.r, &stroke_color.g, &stroke_color.b, + &stroke_color.a); + // Get Stroke Width + FPDFPageObj_GetStrokeWidth(path_object, &stroke_width); + + return true; +} + +PathObject::~PathObject() = default; + +ImageObject::ImageObject() : PageObject(Type::Image) {} + +ScopedFPDFPageObject ImageObject::CreateFPDFInstance(FPDF_DOCUMENT document) { + // Create a scoped PDFium image object. + ScopedFPDFPageObject scoped_image_object(FPDFPageObj_NewImageObj(document)); + if (!scoped_image_object) { + return nullptr; + } + // Update attributes of PDFium image object. + if (!UpdateFPDFInstance(scoped_image_object.get())) { + return nullptr; + } + return scoped_image_object; +} + +bool ImageObject::UpdateFPDFInstance(FPDF_PAGEOBJECT image_object) { + if (!image_object) { + return false; + } + + // Check for Type Correctness. + if (FPDFPageObj_GetType(image_object) != FPDF_PAGEOBJ_IMAGE) { + return false; + } + + // Set the updated bitmap. + if (!FPDFImageObj_SetBitmap(nullptr, 0, image_object, bitmap.get())) { + return false; + } + + // Set the updated matrix. + if (!FPDFPageObj_SetMatrix(image_object, reinterpret_cast<FS_MATRIX*>(&matrix))) { + return false; + } + + width = FPDFBitmap_GetWidth(bitmap.get()); + height = FPDFBitmap_GetHeight(bitmap.get()); + + return true; +} + +bool ImageObject::PopulateFromFPDFInstance(FPDF_PAGEOBJECT image_object) { + // Get Bitmap + bitmap = ScopedFPDFBitmap(FPDFImageObj_GetBitmap(image_object)); + if (bitmap.get() == nullptr) { + return false; + } + + // Get Matrix + if (!FPDFPageObj_GetMatrix(image_object, reinterpret_cast<FS_MATRIX*>(&matrix))) { + return false; + } + + width = FPDFBitmap_GetWidth(bitmap.get()); + height = FPDFBitmap_GetHeight(bitmap.get()); + + return true; +} + +void* ImageObject::GetBitmapReadableBuffer() const { + return FPDFBitmap_GetBuffer(bitmap.get()); +} + +ImageObject::~ImageObject() = default; + +} // namespace pdfClient
\ No newline at end of file diff --git a/pdf/framework/libs/pdfClient/page_object.h b/pdf/framework/libs/pdfClient/page_object.h index b93c15cda..1fdd50784 100644 --- a/pdf/framework/libs/pdfClient/page_object.h +++ b/pdf/framework/libs/pdfClient/page_object.h @@ -19,14 +19,15 @@ #include <stdint.h> +#include <vector> + +#include "cpp/fpdf_scopers.h" #include "fpdfview.h" typedef unsigned int uint; namespace pdfClient { -struct ImageObject; - struct Color { uint r; uint g; @@ -40,39 +41,93 @@ struct Color { static constexpr uint INVALID_COLOR = 256; }; -struct PageObject { +struct Matrix { + float a; + float b; + float c; + float d; + float e; + float f; +}; + +class PageObject { + public: enum class Type { Unknown = 0, - Image, + Path = 2, + Image = 3, }; - PageObject(Type t = Type::Unknown) : type(t) {} - - // Returns a pointer to the ImageObject if the PageObject is of type Image. - // Returns nullptr otherwise. - virtual ImageObject* AsImage() { return nullptr; } + Type GetType() const; + // Returns a FPDF Instance for a PageObject. + virtual ScopedFPDFPageObject CreateFPDFInstance(FPDF_DOCUMENT document) = 0; + // Updates the FPDF Instance of PageObject present on Page. + virtual bool UpdateFPDFInstance(FPDF_PAGEOBJECT page_object) = 0; + // Populates data from FPDFInstance of PageObject present on Page. + virtual bool PopulateFromFPDFInstance(FPDF_PAGEOBJECT page_object) = 0; - Type GetType() { return type; } + virtual ~PageObject(); - virtual ~PageObject() = default; - - FS_MATRIX matrix; // Matrix used to scale, rotate, shear and translate the page object. + Matrix matrix; // Matrix used to scale, rotate, shear and translate the page object. Color fill_color; Color stroke_color; float stroke_width = 1.0f; + protected: + PageObject(Type type = Type::Unknown); + private: Type type; }; -struct ImageObject : public PageObject { - ImageObject() : PageObject(Type::Image) {} +class PathObject : public PageObject { + public: + PathObject(); + + ScopedFPDFPageObject CreateFPDFInstance(FPDF_DOCUMENT document) override; + bool UpdateFPDFInstance(FPDF_PAGEOBJECT path_object) override; + bool PopulateFromFPDFInstance(FPDF_PAGEOBJECT path_object) override; + + ~PathObject(); + + class Segment { + public: + enum class Command { + Unknown = 0, + Move, + Line, + }; + + Command command; + float x; + float y; + bool is_closed; // Checks if the path_segment is closed + + Segment(Command command, float x, float y, bool is_closed = false) + : command(command), x(x), y(y), is_closed(is_closed) {} + }; + + bool is_fill_mode; + bool is_stroke; + + std::vector<Segment> segments; +}; + +class ImageObject : public PageObject { + public: + ImageObject(); + + ScopedFPDFPageObject CreateFPDFInstance(FPDF_DOCUMENT document) override; + bool UpdateFPDFInstance(FPDF_PAGEOBJECT image_object) override; + bool PopulateFromFPDFInstance(FPDF_PAGEOBJECT image_object) override; - ImageObject* AsImage() override { return this; } + void* GetBitmapReadableBuffer() const; - ~ImageObject() { FPDFBitmap_Destroy(bitmap); } + ~ImageObject(); - FPDF_BITMAP bitmap; + int width; + int height; + ScopedFPDFBitmap bitmap; }; } // namespace pdfClient diff --git a/pdf/framework/libs/pdfClient/page_test.cc b/pdf/framework/libs/pdfClient/page_test.cc index 5e6493fc6..a1e5007df 100644 --- a/pdf/framework/libs/pdfClient/page_test.cc +++ b/pdf/framework/libs/pdfClient/page_test.cc @@ -34,15 +34,20 @@ namespace { +using ::pdfClient::Annotation; +using ::pdfClient::Color; using ::pdfClient::Document; using ::pdfClient::ImageObject; using ::pdfClient::Page; using ::pdfClient::PageObject; +using ::pdfClient::PathObject; using ::pdfClient::Rectangle_i; +using ::pdfClient::StampAnnotation; static const std::string kTestdata = "testdata"; static const std::string kSekretNoPassword = "sekret_no_password.pdf"; -static const std::string kImageObject = "image_object.pdf"; +static const std::string kPageObject = "page_object.pdf"; +static const std::string kAnnotation = "annotation.pdf"; std::string GetTestDataDir() { return android::base::GetExecutableDirectory(); @@ -205,19 +210,21 @@ TEST(Test, InvalidPageNumberTest) { } TEST(Test, GetPageObjectsTest) { - Document doc(LoadTestDocument(kImageObject), false); + Document doc(LoadTestDocument(kPageObject), false); std::shared_ptr<Page> page = doc.GetPage(0); std::vector<PageObject*> pageObjects = page->GetPageObjects(); // Check for PageObjects size. - ASSERT_EQ(1, pageObjects.size()); - // Check for the PageObject to be ImageObject. + ASSERT_EQ(2, pageObjects.size()); + // Check for the first PageObject to be ImageObject. ASSERT_EQ(PageObject::Type::Image, pageObjects[0]->GetType()); + // Check for the second PageObject to be PathObject. + ASSERT_EQ(PageObject::Type::Path, pageObjects[1]->GetType()); } -TEST(Test, AddPageObjectTest) { - Document doc(LoadTestDocument(kImageObject), false); +TEST(Test, AddImagePageObjectTest) { + Document doc(LoadTestDocument(kPageObject), false); std::shared_ptr<Page> page = doc.GetPage(0); std::vector<PageObject*> initialPageObjects = page->GetPageObjects(); @@ -226,9 +233,8 @@ TEST(Test, AddPageObjectTest) { auto imageObject = std::make_unique<ImageObject>(); // Create FPDF Bitmap. - FPDF_BITMAP bitmap = FPDFBitmap_Create(6000, 4000, 1); - FPDFBitmap_FillRect(bitmap, 0, 0, 3000, 4000, 0xFF000000); - imageObject->bitmap = bitmap; + imageObject->bitmap = ScopedFPDFBitmap(FPDFBitmap_Create(100, 100, 1)); + FPDFBitmap_FillRect(imageObject->bitmap.get(), 0, 0, 100, 100, 0xFF000000); // Set Matrix. imageObject->matrix = {1.0f, 0, 0, 1.0f, 0, 0}; @@ -241,10 +247,53 @@ TEST(Test, AddPageObjectTest) { // Assert that the size has increased by one. ASSERT_EQ(initialPageObjects.size() + 1, updatedPageObjects.size()); + // Check for the first PageObject to be ImageObject. + ASSERT_EQ(PageObject::Type::Image, updatedPageObjects[0]->GetType()); + // Check for the second PageObject to be PathObject. + ASSERT_EQ(PageObject::Type::Path, updatedPageObjects[1]->GetType()); + // Check for the first PageObject to be ImageObject. + ASSERT_EQ(PageObject::Type::Image, updatedPageObjects[2]->GetType()); +} + +TEST(Test, AddPathPageObject) { + Document doc(LoadTestDocument(kPageObject), false); + std::shared_ptr<Page> page = doc.GetPage(0); + + std::vector<PageObject*> initialPageObjects = page->GetPageObjects(); + + // Create Path Object. + auto pathObject = std::make_unique<PathObject>(); + + // Command Simple Path + pathObject->segments.emplace_back(PathObject::Segment::Command::Move, 0.0f, 0.0f); + pathObject->segments.emplace_back(PathObject::Segment::Command::Line, 100.0f, 150.0f); + pathObject->segments.emplace_back(PathObject::Segment::Command::Line, 150.0f, 150.0f); + + // Set Draw Mode + pathObject->is_fill_mode = false; + pathObject->is_stroke = true; + + // Set PathObject Matrix. + pathObject->matrix = {1.0f, 0, 0, 1.0f, 0, 0}; + + // Add the page object. + ASSERT_EQ(page->AddPageObject(std::move(pathObject)), initialPageObjects.size()); + + // Get Updated PageObjects + std::vector<PageObject*> updatedPageObjects = page->GetPageObjects(true); + + // Assert that the size has increased by one. + ASSERT_EQ(initialPageObjects.size() + 1, updatedPageObjects.size()); + // Check for the first PageObject to be ImageObject. + ASSERT_EQ(PageObject::Type::Image, updatedPageObjects[0]->GetType()); + // Check for the second PageObject to be PathObject. + ASSERT_EQ(PageObject::Type::Path, updatedPageObjects[1]->GetType()); + // Check for the first PageObject to be PathObject. + ASSERT_EQ(PageObject::Type::Path, updatedPageObjects[2]->GetType()); } TEST(Test, RemovePageObjectTest) { - Document doc(LoadTestDocument(kImageObject), false); + Document doc(LoadTestDocument(kPageObject), false); std::shared_ptr<Page> page = doc.GetPage(0); @@ -258,8 +307,8 @@ TEST(Test, RemovePageObjectTest) { ASSERT_EQ(initialPageObjects.size() - 1, updatedPageObjects.size()); } -TEST(Test, UpdatePageObjectTest) { - Document doc(LoadTestDocument(kImageObject), false); +TEST(Test, UpdateImagePageObjectTest) { + Document doc(LoadTestDocument(kPageObject), false); std::shared_ptr<Page> page = doc.GetPage(0); // Get initial page objects. @@ -269,9 +318,8 @@ TEST(Test, UpdatePageObjectTest) { auto imageObject = std::make_unique<ImageObject>(); // Create FPDF Bitmap. - FPDF_BITMAP bitmap = FPDFBitmap_Create(6000, 4000, 1); - FPDFBitmap_FillRect(bitmap, 0, 0, 6000, 4000, 0xFF0000FF); - imageObject->bitmap = bitmap; + imageObject->bitmap = ScopedFPDFBitmap(FPDFBitmap_Create(100, 110, 1)); + FPDFBitmap_FillRect(imageObject->bitmap.get(), 0, 0, 100, 110, 0xFF0000FF); // Set Matrix. imageObject->matrix = {2.0f, 0, 0, 2.0f, 0, 0}; @@ -286,8 +334,10 @@ TEST(Test, UpdatePageObjectTest) { ASSERT_EQ(initialPageObjects.size(), updatedPageObjects.size()); // Check for updated bitmap. - ASSERT_EQ(FPDFBitmap_GetWidth(updatedPageObjects[0]->AsImage()->bitmap), 6000); - ASSERT_EQ(FPDFBitmap_GetHeight(updatedPageObjects[0]->AsImage()->bitmap), 4000); + ASSERT_EQ(FPDFBitmap_GetWidth(static_cast<ImageObject*>(updatedPageObjects[0])->bitmap.get()), + 100); + ASSERT_EQ(FPDFBitmap_GetHeight(static_cast<ImageObject*>(updatedPageObjects[0])->bitmap.get()), + 110); // Check for updated matrix. ASSERT_EQ(updatedPageObjects[0]->matrix.a, 2.0f); @@ -298,4 +348,157 @@ TEST(Test, UpdatePageObjectTest) { ASSERT_EQ(updatedPageObjects[0]->matrix.f, 0.0f); } +TEST(Test, UpdatePathPageObjectTest) { + Document doc(LoadTestDocument(kPageObject), false); + std::shared_ptr<Page> page = doc.GetPage(0); + + // Get initial page objects. + std::vector<PageObject*> initialPageObjects = page->GetPageObjects(); + + // Create Path Object. + auto pathObject = std::make_unique<PathObject>(); + + // Update fill Color. + pathObject->fill_color = Color(255, 0, 0, 255); + + // Update Draw Mode. + pathObject->is_fill_mode = true; + pathObject->is_stroke = false; + + // Set Matrix. + pathObject->matrix = {2.0f, 0, 0, 2.0f, 0, 0}; + + // Update the page object. + EXPECT_TRUE(page->UpdatePageObject(1, std::move(pathObject))); + + // Get the updated page objects. + std::vector<PageObject*> updatedPageObjects = page->GetPageObjects(true); + + // Check for updated fill Color. + ASSERT_EQ(updatedPageObjects[1]->fill_color.r, 255); + ASSERT_EQ(updatedPageObjects[1]->fill_color.b, 0); + ASSERT_EQ(updatedPageObjects[1]->fill_color.g, 0); + ASSERT_EQ(updatedPageObjects[1]->fill_color.a, 255); + + // Check for updated Draw Mode. + ASSERT_EQ(static_cast<PathObject*>(updatedPageObjects[1])->is_fill_mode, true); + ASSERT_EQ(static_cast<PathObject*>(updatedPageObjects[1])->is_stroke, false); + + // Check for updated matrix. + ASSERT_EQ(updatedPageObjects[1]->matrix.a, 2.0f); + ASSERT_EQ(updatedPageObjects[1]->matrix.b, 0.0f); + ASSERT_EQ(updatedPageObjects[1]->matrix.c, 0.0f); + ASSERT_EQ(updatedPageObjects[1]->matrix.d, 2.0f); + ASSERT_EQ(updatedPageObjects[1]->matrix.e, 0.0f); + ASSERT_EQ(updatedPageObjects[1]->matrix.f, 0.0f); +} + +TEST(Test, GetPageAnnotationsTest) { + Document doc(LoadTestDocument(kAnnotation), false); + + std::shared_ptr<Page> page = doc.GetPage(0); + std::vector<Annotation*> annotations = page->GetPageAnnotations(); + + // Check for number of annotations + ASSERT_EQ(1, annotations.size()); + + // Check for the first Annotation to be StampAnnotation. + ASSERT_EQ(Annotation::Type::Stamp, annotations[0]->GetType()); + + StampAnnotation* stamp_annotation = static_cast<StampAnnotation*>(annotations[0]); + std::vector<PageObject*> pageObjects = stamp_annotation->GetObjects(); + + // Check for number of page objects inside stamp annotation + ASSERT_EQ(2, pageObjects.size()); + + // Check for the first PageObject to be ImageObject. + ASSERT_EQ(PageObject::Type::Image, pageObjects[0]->GetType()); + // Check for the second PageObject to be PathObject. + ASSERT_EQ(PageObject::Type::Path, pageObjects[1]->GetType()); +} + +TEST(Test, AddStampAnnotationTest) { + Document doc(LoadTestDocument(kAnnotation), false); + std::shared_ptr<Page> page = doc.GetPage(0); + + std::vector<Annotation*> initialAnnotations = page->GetPageAnnotations(); + + // Bounds for stamp annotation + Rectangle_f bounds = pdfClient::Rectangle_f{0, 300, 200, 0}; + // Create Stamp Annotation + auto stampAnnotation = std::make_unique<StampAnnotation>(bounds); + + // Insert image page object + auto imageObject = std::make_unique<ImageObject>(); + + // Create FPDF Bitmap. + imageObject->bitmap = ScopedFPDFBitmap(FPDFBitmap_Create(100, 100, 1)); + FPDFBitmap_FillRect(imageObject->bitmap.get(), 0, 0, 100, 100, 0xFF000000); + + // Set Matrix. + imageObject->matrix = {1.0f, 0, 0, 1.0f, 0, 0}; + + // Add the page object. + stampAnnotation->AddObject(std::move(imageObject)); + + // Create Path Object. + auto pathObject = std::make_unique<PathObject>(); + + // Command Simple Path + pathObject->segments.emplace_back(PathObject::Segment::Command::Move, 0.0f, 0.0f); + pathObject->segments.emplace_back(PathObject::Segment::Command::Line, 100.0f, 150.0f); + pathObject->segments.emplace_back(PathObject::Segment::Command::Line, 150.0f, 150.0f); + + // Set Draw Mode + pathObject->is_fill_mode = false; + pathObject->is_stroke = true; + + // Set PathObject Matrix. + pathObject->matrix = {1.0f, 0, 0, 1.0f, 0, 0}; + + // Add the page object. + stampAnnotation->AddObject(std::move(pathObject)); + + // Add the stamp annotation. + ASSERT_EQ(page->AddPageAnnotation(std::move(stampAnnotation)), initialAnnotations.size()); + + // Get Updated annotations + std::vector<Annotation*> updatedAnnotations = page->GetPageAnnotations(); + + // Assert that the size has increased by one. + ASSERT_EQ(initialAnnotations.size() + 1, updatedAnnotations.size()); + // Check for the first Annotation to be StampAnnotation + ASSERT_EQ(Annotation::Type::Stamp, updatedAnnotations[0]->GetType()); + // Check for the second Annotation to be StampAnnotation. + ASSERT_EQ(Annotation::Type::Stamp, updatedAnnotations[1]->GetType()); + + // Check for the page objects inside stamp annotation + StampAnnotation* stamp_annotation = static_cast<StampAnnotation*>(updatedAnnotations[1]); + std::vector<PageObject*> pageObjects = stamp_annotation->GetObjects(); + + // Check for number of page objects inside stamp annotation + ASSERT_EQ(2, pageObjects.size()); + + // Check for the first PageObject to be ImageObject. + ASSERT_EQ(PageObject::Type::Image, pageObjects[0]->GetType()); + // Check for the second PageObject to be PathObject. + ASSERT_EQ(PageObject::Type::Path, pageObjects[1]->GetType()); +} + +TEST(Test, RemovePageAnnotationTest) { + Document doc(LoadTestDocument(kAnnotation), false); + + std::shared_ptr<Page> page = doc.GetPage(0); + + std::vector<Annotation*> initialAnnotations = page->GetPageAnnotations(); + + EXPECT_TRUE(initialAnnotations.size() > 0); + // Remove an annotation + EXPECT_TRUE(page->RemovePageAnnotation(0)); + // Get Updated annotations after removal + std::vector<Annotation*> updatedAnnotations = page->GetPageAnnotations(); + + ASSERT_EQ(initialAnnotations.size() - 1, updatedAnnotations.size()); +} + } // namespace
\ No newline at end of file diff --git a/pdf/framework/libs/pdfClient/pdf_document_jni.cc b/pdf/framework/libs/pdfClient/pdf_document_jni.cc index 01e8c58bb..3cc604d31 100644 --- a/pdf/framework/libs/pdfClient/pdf_document_jni.cc +++ b/pdf/framework/libs/pdfClient/pdf_document_jni.cc @@ -319,8 +319,8 @@ Java_android_graphics_pdf_PdfDocumentProxy_isPdfLinearized(JNIEnv* env, jobject return doc->IsLinearized(); } -JNIEXPORT jint JNICALL Java_android_graphics_pdf_PdfDocumentProxy_getFormType(JNIEnv* env, - jobject jPdfDocument) { +JNIEXPORT jint JNICALL Java_android_graphics_pdf_PdfDocumentProxy_getFormType( + JNIEnv* env, jobject jPdfDocument) { std::unique_lock<std::mutex> lock(mutex_); Document* doc = convert::GetPdfDocPtr(env, jPdfDocument); return doc->GetFormType(); @@ -446,3 +446,63 @@ JNIEXPORT jobject JNICALL Java_android_graphics_pdf_PdfDocumentProxy_setFormFiel doc->ReleaseRetainedPage(pageNum); return convert::ToJavaRects(env, invalid_rects); } + +JNIEXPORT jint JNICALL Java_android_graphics_pdf_PdfDocumentProxy_addPageObject( + JNIEnv* env, jobject jPdfDocument, jint pageNum, jobject jPageObject) { + std::unique_lock<std::mutex> lock(mutex_); + Document* doc = convert::GetPdfDocPtr(env, jPdfDocument); + std::shared_ptr<Page> page = doc->GetPage(pageNum, true); + + std::unique_ptr<PageObject> page_object = convert::ToNativePageObject(env, jPageObject); + + if (!page_object) { + return -1; + } + + int new_object_index = page->AddPageObject(std::move(page_object)); + + doc->ReleaseRetainedPage(pageNum); + return new_object_index; +} + +JNIEXPORT jobject JNICALL Java_android_graphics_pdf_PdfDocumentProxy_getPageObjects( + JNIEnv* env, jobject jPdfDocument, jint pageNum) { + std::unique_lock<std::mutex> lock(mutex_); + Document* doc = convert::GetPdfDocPtr(env, jPdfDocument); + std::shared_ptr<Page> page = doc->GetPage(pageNum, true); + + std::vector<PageObject*> page_objects = page->GetPageObjects(); + + doc->ReleaseRetainedPage(pageNum); + return convert::ToJavaPdfPageObjects(env, page_objects); +} + +JNIEXPORT jboolean JNICALL Java_android_graphics_pdf_PdfDocumentProxy_removePageObject( + JNIEnv* env, jobject jPdfDocument, jint pageNum, jint index) { + std::unique_lock<std::mutex> lock(mutex_); + Document* doc = convert::GetPdfDocPtr(env, jPdfDocument); + std::shared_ptr<Page> page = doc->GetPage(pageNum, true); + + bool removed = page->RemovePageObject(index); + + doc->ReleaseRetainedPage(pageNum); + return removed; +} + +JNIEXPORT jboolean JNICALL Java_android_graphics_pdf_PdfDocumentProxy_updatePageObject( + JNIEnv* env, jobject jPdfDocument, jint pageNum, jint index, jobject jPageObject) { + std::unique_lock<std::mutex> lock(mutex_); + Document* doc = convert::GetPdfDocPtr(env, jPdfDocument); + std::shared_ptr<Page> page = doc->GetPage(pageNum, true); + + std::unique_ptr<PageObject> page_object = convert::ToNativePageObject(env, jPageObject); + + if (!page_object) { + return false; + } + + bool updated = page->UpdatePageObject(index, std::move(page_object)); + + doc->ReleaseRetainedPage(pageNum); + return updated; +} diff --git a/pdf/framework/libs/pdfClient/pdf_document_jni.h b/pdf/framework/libs/pdfClient/pdf_document_jni.h index e6cad3c57..e71937de2 100644 --- a/pdf/framework/libs/pdfClient/pdf_document_jni.h +++ b/pdf/framework/libs/pdfClient/pdf_document_jni.h @@ -109,6 +109,18 @@ JNIEXPORT jobject JNICALL Java_android_graphics_pdf_PdfDocumentProxy_setFormFiel JNIEnv* env, jobject jPdfDocument, jint pageNum, jint annotationIndex, jintArray jSelectedIndices); +JNIEXPORT jint JNICALL Java_android_graphics_pdf_PdfDocumentProxy_addPageObject( + JNIEnv* env, jobject jPdfDocument, jint pageNum, jobject jPageObject); + +JNIEXPORT jobject JNICALL Java_android_graphics_pdf_PdfDocumentProxy_getPageObjects( + JNIEnv* env, jobject jPdfDocument, jint pageNum); + +JNIEXPORT jboolean JNICALL Java_android_graphics_pdf_PdfDocumentProxy_removePageObject( + JNIEnv* env, jobject jPdfDocument, jint pageNum, jint index); + +JNIEXPORT jboolean JNICALL Java_android_graphics_pdf_PdfDocumentProxy_updatePageObject( + JNIEnv* env, jobject jPdfDocument, jint pageNum, jint index, jobject jPageObject); + #ifdef __cplusplus } #endif diff --git a/pdf/framework/libs/pdfClient/rect.h b/pdf/framework/libs/pdfClient/rect.h index bb708f76b..d8d28144a 100644 --- a/pdf/framework/libs/pdfClient/rect.h +++ b/pdf/framework/libs/pdfClient/rect.h @@ -33,6 +33,12 @@ struct Point_d { double y; }; +// A point with float precision +struct Point_f { + float x; + float y; +}; + // A rectangle with integer precision struct Rectangle_i { // The x-coordinate of the left-top corner (lesser value). @@ -69,6 +75,24 @@ struct Rectangle_d { Point_d Center() const { return Point_d{(left + right) / 2, (top + bottom) / 2}; } }; +// A rectangle with float precision +struct Rectangle_f { + // The x-coordinate of the left-top corner (lesser value). + float left; + // The y-coordinate of the left-top corner (lesser value). + float top; + // The x-coordinate of the right-bottom corner (greater value). + float right; + // The y-coordinate of the right-bottom corner (greater value). + float bottom; + + float Width() const { return right - left; } + + float Height() const { return bottom - top; } + + Point_f Center() const { return Point_f{(left + right) / 2, (top + bottom) / 2}; } +}; + // Check if two points are equal: inline bool operator==(const Point_i& lhs, const Point_i& rhs) { return lhs.x == rhs.x && lhs.y == rhs.y; @@ -127,6 +151,8 @@ Rectangle_d Intersect(const Rectangle_d& lhs, const Rectangle_d& rhs); Rectangle_i Union(const Rectangle_i& lhs, const Rectangle_i& rhs); Rectangle_d Union(const Rectangle_d& lhs, const Rectangle_d& rhs); +Rectangle_f FloatRect(const float x1, const float y1, const float x2, const float y2); + } // namespace pdfClient #endif // MEDIAPROVIDER_PDF_JNI_PDFCLIENT_RECT_H_
\ No newline at end of file diff --git a/pdf/framework/libs/pdfClient/testdata/annotation.pdf b/pdf/framework/libs/pdfClient/testdata/annotation.pdf Binary files differnew file mode 100644 index 000000000..e87600e8c --- /dev/null +++ b/pdf/framework/libs/pdfClient/testdata/annotation.pdf diff --git a/pdf/framework/libs/pdfClient/testdata/image_object.pdf b/pdf/framework/libs/pdfClient/testdata/image_object.pdf Binary files differdeleted file mode 100755 index 36d9ffbdd..000000000 --- a/pdf/framework/libs/pdfClient/testdata/image_object.pdf +++ /dev/null diff --git a/pdf/framework/libs/pdfClient/testdata/page_object.pdf b/pdf/framework/libs/pdfClient/testdata/page_object.pdf Binary files differnew file mode 100644 index 000000000..81215acb2 --- /dev/null +++ b/pdf/framework/libs/pdfClient/testdata/page_object.pdf diff --git a/photopicker/res/values-af/feature_privacy_explainer_strings.xml b/photopicker/res/values-af/feature_privacy_explainer_strings.xml index c6f0850a1..1a73240f7 100644 --- a/photopicker/res/values-af/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-af/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> sal net toegang hê tot die foto’s wat jy kies"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Kies foto’s en video’s waartoe jy <xliff:g id="APP_NAME">%1$s</xliff:g> toegang gee"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Hierdie app"</string> </resources> diff --git a/photopicker/res/values-am/feature_privacy_explainer_strings.xml b/photopicker/res/values-am/feature_privacy_explainer_strings.xml index 355eac219..5298da10b 100644 --- a/photopicker/res/values-am/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-am/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> እርስዎ ለመረጧቸው ፎቶዎች ብቻ መዳረሻ ይኖረዋል"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> እንዲደርስ እርስዎ የፈቀዷቸውን ፎቶዎች እና ቪድዮዎች ይምረጡ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"ይህ መተግበሪያ"</string> </resources> diff --git a/photopicker/res/values-ar/feature_privacy_explainer_strings.xml b/photopicker/res/values-ar/feature_privacy_explainer_strings.xml index e9f23706a..deaa3ca86 100644 --- a/photopicker/res/values-ar/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ar/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"سيصبح بإمكان \"<xliff:g id="APP_NAME">%1$s</xliff:g>\" الوصول إلى الصور التي تختارها فقط"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"اختّر الصور والفيديوهات التي سيكون بإمكان \"<xliff:g id="APP_NAME">%1$s</xliff:g>\" الوصول إليها"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"هذا التطبيق"</string> </resources> diff --git a/photopicker/res/values-as/feature_privacy_explainer_strings.xml b/photopicker/res/values-as/feature_privacy_explainer_strings.xml index 72d16f3af..b8f9f248e 100644 --- a/photopicker/res/values-as/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-as/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g>এ কেৱল আপুনি বাছনি কৰা ফট’হে এক্সেছ কৰিব পাৰিব"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"আপুনি <xliff:g id="APP_NAME">%1$s</xliff:g>ক এক্সেছ কৰিবলৈ অনুমতি দিয়া ফট’ আৰু ভিডিঅ’ বাছনি কৰক"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"এইটো এপ্"</string> </resources> diff --git a/photopicker/res/values-az/feature_privacy_explainer_strings.xml b/photopicker/res/values-az/feature_privacy_explainer_strings.xml index e5a816748..ecd21864c 100644 --- a/photopicker/res/values-az/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-az/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> yalnız seçdiyiniz fotolara daxil ola biləcək"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> tətbiqinə giriş imkanı verdiyiniz foto və videoları seçin"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Bu tətbiq"</string> </resources> diff --git a/photopicker/res/values-b+sr+Latn/feature_privacy_explainer_strings.xml b/photopicker/res/values-b+sr+Latn/feature_privacy_explainer_strings.xml index 965a66bf9..a6e598bef 100644 --- a/photopicker/res/values-b+sr+Latn/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-b+sr+Latn/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> će imati pristup samo slikama koje izaberete"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Izaberite slike i video snimke kojima <xliff:g id="APP_NAME">%1$s</xliff:g> može da pristupi"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ova aplikacija"</string> </resources> diff --git a/photopicker/res/values-be/feature_privacy_explainer_strings.xml b/photopicker/res/values-be/feature_privacy_explainer_strings.xml index 41f9b1819..7cba83370 100644 --- a/photopicker/res/values-be/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-be/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Праграма \"<xliff:g id="APP_NAME">%1$s</xliff:g>\" будзе мець доступ толькі да выбраных вамі фота"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Выберыце фота і відэа, да якіх праграма \"<xliff:g id="APP_NAME">%1$s</xliff:g>\" можа атрымліваць доступ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Гэта праграма"</string> </resources> diff --git a/photopicker/res/values-bg/feature_privacy_explainer_strings.xml b/photopicker/res/values-bg/feature_privacy_explainer_strings.xml index 33a1653dd..6d9ae89d7 100644 --- a/photopicker/res/values-bg/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-bg/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> ще осъществява достъп само до избраните от вас снимки"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Изберете снимки и видеоклипове, до които разрешавате да осъществява достъп <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Това приложение"</string> </resources> diff --git a/photopicker/res/values-bn/feature_privacy_explainer_strings.xml b/photopicker/res/values-bn/feature_privacy_explainer_strings.xml index b3625eb36..19e44d2c8 100644 --- a/photopicker/res/values-bn/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-bn/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> শুধুমাত্র আপনার বেছে নেওয়া ফটো অ্যাক্সেস করবে"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"সেইসব ফটো ও ভিডিও বেছে নিন যা অ্যাক্সেস করার অনুমতি <xliff:g id="APP_NAME">%1$s</xliff:g> অ্যাপকে দিয়েছেন"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"এই অ্যাপ"</string> </resources> diff --git a/photopicker/res/values-bs/feature_privacy_explainer_strings.xml b/photopicker/res/values-bs/feature_privacy_explainer_strings.xml index 5713f6dfb..31fae4994 100644 --- a/photopicker/res/values-bs/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-bs/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> će imati pristup samo fotografijama koje odaberete"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Odaberite fotografije i videozapise kojima aplikacija <xliff:g id="APP_NAME">%1$s</xliff:g> može pristupati"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ova aplikacija"</string> </resources> diff --git a/photopicker/res/values-ca/feature_privacy_explainer_strings.xml b/photopicker/res/values-ca/feature_privacy_explainer_strings.xml index 1829dcceb..758561c25 100644 --- a/photopicker/res/values-ca/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ca/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> només tindrà accés a les fotos que seleccionis"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Selecciona les fotos i els vídeos als quals permets que accedeixi <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Aquesta aplicació"</string> </resources> diff --git a/photopicker/res/values-cs/feature_privacy_explainer_strings.xml b/photopicker/res/values-cs/feature_privacy_explainer_strings.xml index 488206440..190c9f19c 100644 --- a/photopicker/res/values-cs/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-cs/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Aplikace <xliff:g id="APP_NAME">%1$s</xliff:g> má přístup jen k fotkám, které vyberete"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Vyberte fotky a videa, ke kterým aplikaci <xliff:g id="APP_NAME">%1$s</xliff:g> chcete povolit přístup"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Tato aplikace"</string> </resources> diff --git a/photopicker/res/values-da/feature_privacy_explainer_strings.xml b/photopicker/res/values-da/feature_privacy_explainer_strings.xml index f427fc24c..586f60011 100644 --- a/photopicker/res/values-da/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-da/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> tilgår kun de billeder, du vælger"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Vælg de billeder og videoer, som du vil give <xliff:g id="APP_NAME">%1$s</xliff:g> tilladelse til at tilgå"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Denne app"</string> </resources> diff --git a/photopicker/res/values-de/feature_privacy_explainer_strings.xml b/photopicker/res/values-de/feature_privacy_explainer_strings.xml index 283139ba1..088f6852d 100644 --- a/photopicker/res/values-de/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-de/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> greift nur auf die von dir ausgewählten Fotos zu"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Auswählen, auf welche Fotos und Videos <xliff:g id="APP_NAME">%1$s</xliff:g> zugreifen darf"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Diese App"</string> </resources> diff --git a/photopicker/res/values-el/feature_privacy_explainer_strings.xml b/photopicker/res/values-el/feature_privacy_explainer_strings.xml index ab1ffe8ca..38ef7c922 100644 --- a/photopicker/res/values-el/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-el/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Η εφαρμογή <xliff:g id="APP_NAME">%1$s</xliff:g> θα έχει πρόσβαση μόνο στις φωτογραφίες που επιλέγετε"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Επιλέξτε τις φωτογραφίες και τα βίντεο στα οποία παραχωρείτε πρόσβαση στην εφαρμογή <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Αυτή η εφαρμογή"</string> </resources> diff --git a/photopicker/res/values-en-rAU/feature_privacy_explainer_strings.xml b/photopicker/res/values-en-rAU/feature_privacy_explainer_strings.xml index 665ee7b79..59f3a29f9 100644 --- a/photopicker/res/values-en-rAU/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-en-rAU/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> will only have access to the photos that you select"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Select photos and videos that you allow <xliff:g id="APP_NAME">%1$s</xliff:g> to access"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"This app"</string> </resources> diff --git a/photopicker/res/values-en-rCA/feature_privacy_explainer_strings.xml b/photopicker/res/values-en-rCA/feature_privacy_explainer_strings.xml index 47f8044d6..914eeb91f 100644 --- a/photopicker/res/values-en-rCA/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-en-rCA/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> will only have access to the photos you select"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Select photos and videos that you allow <xliff:g id="APP_NAME">%1$s</xliff:g> to access"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"This app"</string> </resources> diff --git a/photopicker/res/values-en-rGB/feature_privacy_explainer_strings.xml b/photopicker/res/values-en-rGB/feature_privacy_explainer_strings.xml index 665ee7b79..59f3a29f9 100644 --- a/photopicker/res/values-en-rGB/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-en-rGB/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> will only have access to the photos that you select"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Select photos and videos that you allow <xliff:g id="APP_NAME">%1$s</xliff:g> to access"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"This app"</string> </resources> diff --git a/photopicker/res/values-en-rIN/feature_privacy_explainer_strings.xml b/photopicker/res/values-en-rIN/feature_privacy_explainer_strings.xml index 665ee7b79..59f3a29f9 100644 --- a/photopicker/res/values-en-rIN/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-en-rIN/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> will only have access to the photos that you select"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Select photos and videos that you allow <xliff:g id="APP_NAME">%1$s</xliff:g> to access"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"This app"</string> </resources> diff --git a/photopicker/res/values-es-rUS/feature_privacy_explainer_strings.xml b/photopicker/res/values-es-rUS/feature_privacy_explainer_strings.xml index 36ba9ed57..585531173 100644 --- a/photopicker/res/values-es-rUS/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-es-rUS/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> solo tendrá acceso a las fotos que selecciones"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Selecciona fotos y videos a los que permites que <xliff:g id="APP_NAME">%1$s</xliff:g> acceda"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Esta app"</string> </resources> diff --git a/photopicker/res/values-es/feature_privacy_explainer_strings.xml b/photopicker/res/values-es/feature_privacy_explainer_strings.xml index a30bdda0d..0d34b9d01 100644 --- a/photopicker/res/values-es/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-es/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> solo tendrá acceso a las fotos que selecciones"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Selecciona las fotos y los vídeos a los que <xliff:g id="APP_NAME">%1$s</xliff:g> podrá tener acceso"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Esta aplicación"</string> </resources> diff --git a/photopicker/res/values-et/feature_privacy_explainer_strings.xml b/photopicker/res/values-et/feature_privacy_explainer_strings.xml index 5dbd864df..29eee25c6 100644 --- a/photopicker/res/values-et/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-et/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> pääseb juurde ainult teie valitud fotodele"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Valige fotod ja videod, millele lubate rakendusel <xliff:g id="APP_NAME">%1$s</xliff:g> juurde pääseda"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"See rakendus"</string> </resources> diff --git a/photopicker/res/values-eu/feature_privacy_explainer_strings.xml b/photopicker/res/values-eu/feature_privacy_explainer_strings.xml index 9e414c9d5..7bc63bf15 100644 --- a/photopicker/res/values-eu/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-eu/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Hautatzen dituzun argazkiak erabiltzeko baimena baino ez du izango <xliff:g id="APP_NAME">%1$s</xliff:g> aplikazioak"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Hautatu <xliff:g id="APP_NAME">%1$s</xliff:g> erabili ahalko dituen argazki eta bideoak"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"aplikazio honek"</string> </resources> diff --git a/photopicker/res/values-fa/feature_privacy_explainer_strings.xml b/photopicker/res/values-fa/feature_privacy_explainer_strings.xml index 57d1fd666..0c9f5673a 100644 --- a/photopicker/res/values-fa/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-fa/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> فقط به عکسهایی که شما انتخاب میکنید دسترسی خواهد داشت"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"عکسها و ویدیوهایی را انتخاب کنید که به <xliff:g id="APP_NAME">%1$s</xliff:g> اجازه میدهید به آنها دسترسی داشته باشد"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"این برنامه"</string> </resources> diff --git a/photopicker/res/values-fi/feature_privacy_explainer_strings.xml b/photopicker/res/values-fi/feature_privacy_explainer_strings.xml index a6446f1d5..338dc92bd 100644 --- a/photopicker/res/values-fi/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-fi/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> saa pääsyn vain valitsemiisi kuviin"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Valitse kuvat ja videot, joihin <xliff:g id="APP_NAME">%1$s</xliff:g> saa pääsyn"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Tämä sovellus"</string> </resources> diff --git a/photopicker/res/values-fr-rCA/feature_privacy_explainer_strings.xml b/photopicker/res/values-fr-rCA/feature_privacy_explainer_strings.xml index c50bd9247..9d17988af 100644 --- a/photopicker/res/values-fr-rCA/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-fr-rCA/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> accédera uniquement aux photos que vous sélectionnez"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Sélectionnez les photos et les vidéos auxquelles vous autorisez <xliff:g id="APP_NAME">%1$s</xliff:g> à accéder"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Cette appli"</string> </resources> diff --git a/photopicker/res/values-fr/feature_privacy_explainer_strings.xml b/photopicker/res/values-fr/feature_privacy_explainer_strings.xml index 6340fbf9b..993feb2bd 100644 --- a/photopicker/res/values-fr/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-fr/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> n\'aura accès qu\'aux photos que vous sélectionnez"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Sélectionnez les photos et les vidéos auxquelles vous autorisez <xliff:g id="APP_NAME">%1$s</xliff:g> à accéder"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Cette appli"</string> </resources> diff --git a/photopicker/res/values-gl/feature_privacy_explainer_strings.xml b/photopicker/res/values-gl/feature_privacy_explainer_strings.xml index 48361a6b9..54935d083 100644 --- a/photopicker/res/values-gl/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-gl/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> só terá acceso ás fotos que selecciones"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Selecciona as fotos e os vídeos aos que pode acceder <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Esta aplicación"</string> </resources> diff --git a/photopicker/res/values-gu/feature_privacy_explainer_strings.xml b/photopicker/res/values-gu/feature_privacy_explainer_strings.xml index f5f78f0ff..99aa54a9b 100644 --- a/photopicker/res/values-gu/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-gu/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> માત્ર તમે પસંદ કરેલા ફોટાનો ઍક્સેસ જ ધરાવશે"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"તમે <xliff:g id="APP_NAME">%1$s</xliff:g>ને જે ફોટા અને વીડિયો ઍક્સેસ કરવાની મંજૂરી આપી હોય તેને પસંદ કરો"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"આ ઍપ"</string> </resources> diff --git a/photopicker/res/values-hi/feature_privacy_explainer_strings.xml b/photopicker/res/values-hi/feature_privacy_explainer_strings.xml index 163028100..a37358364 100644 --- a/photopicker/res/values-hi/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-hi/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g>, आपकी चुनी गई फ़ोटो ही ऐक्सेस कर पाएगा"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"उन फ़ोटो और वीडियो को चुनें जिनका ऐक्सेस <xliff:g id="APP_NAME">%1$s</xliff:g> को दिया है"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"इस ऐप्लिकेशन से"</string> </resources> diff --git a/photopicker/res/values-hr/feature_privacy_explainer_strings.xml b/photopicker/res/values-hr/feature_privacy_explainer_strings.xml index 25256f2d2..3428e9bef 100644 --- a/photopicker/res/values-hr/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-hr/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> imat će pristup samo fotografijama koje odaberete"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Odaberite fotografije i videozapise za koje želite da aplikacija <xliff:g id="APP_NAME">%1$s</xliff:g> ima pristup"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ova aplikacija"</string> </resources> diff --git a/photopicker/res/values-hu/feature_privacy_explainer_strings.xml b/photopicker/res/values-hu/feature_privacy_explainer_strings.xml index 10c1831d6..6feeaae0b 100644 --- a/photopicker/res/values-hu/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-hu/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"A(z) <xliff:g id="APP_NAME">%1$s</xliff:g> csak az Ön által kiválasztott fotókhoz férhet majd hozzá"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Válassza ki azokat a fotókat és videókat, amelyekhez engedélyezi a(z) <xliff:g id="APP_NAME">%1$s</xliff:g> számára a hozzáférést"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ez az alkalmazás"</string> </resources> diff --git a/photopicker/res/values-hy/feature_privacy_explainer_strings.xml b/photopicker/res/values-hy/feature_privacy_explainer_strings.xml index cfd0fcd50..24d7ab0e4 100644 --- a/photopicker/res/values-hy/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-hy/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> հավելվածին հասանելի կլինեն միայն ձեր ընտրած լուսանկարները"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Ընտրեք լուսանկարներ և տեսանյութեր, որոնք ուզում եք հասանելի դարձնել <xliff:g id="APP_NAME">%1$s</xliff:g> հավելվածին"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Այս"</string> </resources> diff --git a/photopicker/res/values-in/feature_privacy_explainer_strings.xml b/photopicker/res/values-in/feature_privacy_explainer_strings.xml index 890312ac1..d5cd0feb2 100644 --- a/photopicker/res/values-in/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-in/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> hanya akan memiliki akses ke foto yang Anda pilih"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Pilih foto dan video yang Anda izinkan untuk diakses <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Aplikasi ini"</string> </resources> diff --git a/photopicker/res/values-is/feature_privacy_explainer_strings.xml b/photopicker/res/values-is/feature_privacy_explainer_strings.xml index b2a04fc43..b79b77a43 100644 --- a/photopicker/res/values-is/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-is/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> fær aðeins aðgang að myndunum sem þú velur"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Veldu myndir og vídeó sem þú vilt leyfa <xliff:g id="APP_NAME">%1$s</xliff:g> að hafa aðgang að"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Þetta forrit"</string> </resources> diff --git a/photopicker/res/values-it/feature_privacy_explainer_strings.xml b/photopicker/res/values-it/feature_privacy_explainer_strings.xml index d56287719..d13de860a 100644 --- a/photopicker/res/values-it/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-it/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> accederà solo alle foto che selezioni"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Seleziona foto e video a cui l\'app <xliff:g id="APP_NAME">%1$s</xliff:g> può accedere"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Questa app"</string> </resources> diff --git a/photopicker/res/values-iw/feature_category_grid_strings.xml b/photopicker/res/values-iw/feature_category_grid_strings.xml new file mode 100644 index 000000000..92e1c4ee1 --- /dev/null +++ b/photopicker/res/values-iw/feature_category_grid_strings.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + ~ 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. + --> + +<resources xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="photopicker_categories_nav_button_label" msgid="988315710756227148">"אוספים"</string> +</resources> diff --git a/photopicker/res/values-iw/feature_privacy_explainer_strings.xml b/photopicker/res/values-iw/feature_privacy_explainer_strings.xml index 61ad525a4..5ae8eb3fd 100644 --- a/photopicker/res/values-iw/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-iw/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> תקבל גישה רק לתמונות שבחרת"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"בחירה של תמונות וסרטונים שתהיה ל-<xliff:g id="APP_NAME">%1$s</xliff:g> גישה אליהם"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"האפליקציה הזו"</string> </resources> diff --git a/photopicker/res/values-ja/feature_privacy_explainer_strings.xml b/photopicker/res/values-ja/feature_privacy_explainer_strings.xml index c15fe4849..90816b010 100644 --- a/photopicker/res/values-ja/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ja/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g>は選択された写真にのみアクセスできます"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g>にアクセスを許可する写真と動画を選択してください"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"このアプリ"</string> </resources> diff --git a/photopicker/res/values-ka/feature_privacy_explainer_strings.xml b/photopicker/res/values-ka/feature_privacy_explainer_strings.xml index aa5aa6428..188e8d4b7 100644 --- a/photopicker/res/values-ka/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ka/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g>-ს მხოლოდ თქვენ მიერ არჩეულ ფოტოებზე ექნება წვდომა"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"აირჩიეთ ფოტოები და ვიდეოები, რომლებზეც <xliff:g id="APP_NAME">%1$s</xliff:g>-ს წვდომის უფლებას აძლევთ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"ეს აპი"</string> </resources> diff --git a/photopicker/res/values-kk/feature_privacy_explainer_strings.xml b/photopicker/res/values-kk/feature_privacy_explainer_strings.xml index 77e91d579..14c943280 100644 --- a/photopicker/res/values-kk/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-kk/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> қолданбасы тек сіз таңдаған фотосуреттерді пайдалана алады."</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> қолданбасына пайдалануға рұқсат беретін фотосуреттер мен бейнелерді таңдаңыз."</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Осы қолданба"</string> </resources> diff --git a/photopicker/res/values-km/feature_privacy_explainer_strings.xml b/photopicker/res/values-km/feature_privacy_explainer_strings.xml index 5ca1c2007..3815858db 100644 --- a/photopicker/res/values-km/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-km/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> នឹងមានសិទ្ធិចូលប្រើប្រាស់បានតែរូបថតដែលអ្នកជ្រើសរើសប៉ុណ្ណោះ"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"ជ្រើសរើសរូបថត និងវីដេអូដែលអ្នកអនុញ្ញាតឱ្យ <xliff:g id="APP_NAME">%1$s</xliff:g> ចូលប្រើ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"កម្មវិធីនេះ"</string> </resources> diff --git a/photopicker/res/values-kn/feature_privacy_explainer_strings.xml b/photopicker/res/values-kn/feature_privacy_explainer_strings.xml index 2c8c5d28f..3406684e7 100644 --- a/photopicker/res/values-kn/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-kn/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"ನೀವು ಆಯ್ಕೆ ಮಾಡಿದ ಫೋಟೋಗಳಿಗೆ ಮಾತ್ರ <xliff:g id="APP_NAME">%1$s</xliff:g> ಆ್ಯಕ್ಸೆಸ್ ಅನ್ನು ಹೊಂದಿರುತ್ತದೆ"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"ನೀವು <xliff:g id="APP_NAME">%1$s</xliff:g> ಗೆ ಆ್ಯಕ್ಸೆಸ್ ಮಾಡಲು ಅನುಮತಿಸುವ ಫೋಟೋಗಳು ಮತ್ತು ವೀಡಿಯೊಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"ಈ ಆ್ಯಪ್"</string> </resources> diff --git a/photopicker/res/values-ko/feature_privacy_explainer_strings.xml b/photopicker/res/values-ko/feature_privacy_explainer_strings.xml index 76113be8f..d8d456271 100644 --- a/photopicker/res/values-ko/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ko/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g>에서 내가 선택한 사진에만 액세스합니다."</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g>에 액세스하도록 허용할 사진과 동영상을 선택하세요"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"이 앱"</string> </resources> diff --git a/photopicker/res/values-ky/feature_privacy_explainer_strings.xml b/photopicker/res/values-ky/feature_privacy_explainer_strings.xml index 3c18ce1e1..4d931bcc7 100644 --- a/photopicker/res/values-ky/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ky/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> сиз тандаган сүрөттөрдү гана колдонот"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> колдоно алган сүрөттөр менен видеолорду тандаңыз"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ушул колдонмо"</string> </resources> diff --git a/photopicker/res/values-lo/feature_privacy_explainer_strings.xml b/photopicker/res/values-lo/feature_privacy_explainer_strings.xml index 79a2caee3..efbabcb08 100644 --- a/photopicker/res/values-lo/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-lo/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> ຈະມີສິດເຂົ້າເຖິງໄດ້ສະເພາະຮູບພາບທີ່ທ່ານເລືອກເທົ່ານັ້ນ"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"ເລືອກຮູບພາບ ແລະ ວິດີໂອທີ່ທ່ານອະນຸຍາດໃຫ້ <xliff:g id="APP_NAME">%1$s</xliff:g> ເຂົ້າເຖິງ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"ແອັບນີ້"</string> </resources> diff --git a/photopicker/res/values-lt/feature_privacy_explainer_strings.xml b/photopicker/res/values-lt/feature_privacy_explainer_strings.xml index 94868fab6..b299df4db 100644 --- a/photopicker/res/values-lt/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-lt/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"„<xliff:g id="APP_NAME">%1$s</xliff:g>“ gali pasiekti tik jūsų pasirinktas nuotraukas"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Pasirinkite nuotraukas ir vaizdo įrašus, kuriuos galės pasiekti <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ši programa"</string> </resources> diff --git a/photopicker/res/values-lv/feature_privacy_explainer_strings.xml b/photopicker/res/values-lv/feature_privacy_explainer_strings.xml index 1f7a848e1..3ed1583fb 100644 --- a/photopicker/res/values-lv/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-lv/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Lietotne <xliff:g id="APP_NAME">%1$s</xliff:g> varēs piekļūt tikai jūsu atlasītajiem fotoattēliem."</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Atlasiet fotoattēlus un videoklipus, kam <xliff:g id="APP_NAME">%1$s</xliff:g> drīkst piekļūt."</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Šī lietotne"</string> </resources> diff --git a/photopicker/res/values-mk/feature_privacy_explainer_strings.xml b/photopicker/res/values-mk/feature_privacy_explainer_strings.xml index 2535b901e..95e30b263 100644 --- a/photopicker/res/values-mk/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-mk/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> ќе има пристап само до фотографиите што ќе ги изберете"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Изберете фотографии и видеа до коишто дозволувате да пристапи <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Оваа апликација"</string> </resources> diff --git a/photopicker/res/values-ml/feature_privacy_explainer_strings.xml b/photopicker/res/values-ml/feature_privacy_explainer_strings.xml index 04d4917d9..7c27aceb4 100644 --- a/photopicker/res/values-ml/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ml/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"നിങ്ങൾ തിരഞ്ഞെടുക്കുന്ന ഫോട്ടോകളിലേക്ക് മാത്രമേ <xliff:g id="APP_NAME">%1$s</xliff:g> എന്നതിന് ആക്സസ് ഉണ്ടാകൂ"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> എന്നതിനെ നിങ്ങൾ ആക്സസ് ചെയ്യാൻ അനുവദിക്കുന്ന ഫോട്ടോകളും വീഡിയോകളും തിരഞ്ഞെടുക്കുക"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"ഈ ആപ്പ്"</string> </resources> diff --git a/photopicker/res/values-mn/feature_privacy_explainer_strings.xml b/photopicker/res/values-mn/feature_privacy_explainer_strings.xml index 773cc4354..0bc45ed5a 100644 --- a/photopicker/res/values-mn/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-mn/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> зөвхөн таны сонгосон зурагт хандах эрхтэй байх болно"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g>-д хандахыг зөвшөөрөх зураг болон видеогоо сонгоно уу"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Энэ апп"</string> </resources> diff --git a/photopicker/res/values-mr/feature_privacy_explainer_strings.xml b/photopicker/res/values-mr/feature_privacy_explainer_strings.xml index 4bf35929b..41bbbb0bb 100644 --- a/photopicker/res/values-mr/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-mr/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> हे फक्त तुम्ही निवडलेले फोटो अॅक्सेस करू शकेल"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"तुम्ही <xliff:g id="APP_NAME">%1$s</xliff:g> ला अॅक्सेस करण्याची अनुमती देत असलेले फोटो आणि व्हिडिओ निवडा"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"हे अॅप"</string> </resources> diff --git a/photopicker/res/values-ms/feature_privacy_explainer_strings.xml b/photopicker/res/values-ms/feature_privacy_explainer_strings.xml index 2357caca6..f074bdc5c 100644 --- a/photopicker/res/values-ms/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ms/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> hanya akan mengakses foto yang anda pilih"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Pilih foto dan video yang boleh diakses oleh <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Apl ini"</string> </resources> diff --git a/photopicker/res/values-my/feature_privacy_explainer_strings.xml b/photopicker/res/values-my/feature_privacy_explainer_strings.xml index fc7ef7e51..1ead222f7 100644 --- a/photopicker/res/values-my/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-my/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> သည် သင်ရွေးသော ဓာတ်ပုံများကိုသာ သုံးခွင့်ရှိမည်"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> အား သင်သုံးခွင့်ပြုသော ဓာတ်ပုံနှင့် ဗီဒီယိုများကို ရွေးပါ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"ဤအက်ပ်"</string> </resources> diff --git a/photopicker/res/values-nb/feature_privacy_explainer_strings.xml b/photopicker/res/values-nb/feature_privacy_explainer_strings.xml index 904396ce1..95ad3ca7d 100644 --- a/photopicker/res/values-nb/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-nb/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> har bare tilgang til bildene du velger"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Velg bilder og videoer du vil at <xliff:g id="APP_NAME">%1$s</xliff:g> skal kunne bruke"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Denne appen"</string> </resources> diff --git a/photopicker/res/values-ne/feature_privacy_explainer_strings.xml b/photopicker/res/values-ne/feature_privacy_explainer_strings.xml index e5de14c55..f1aa17a53 100644 --- a/photopicker/res/values-ne/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ne/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> तपाईंले चयन गरेका फोटोहरू मात्र एक्सेस गर्न सक्ने छ"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"तपाईंले <xliff:g id="APP_NAME">%1$s</xliff:g> लाई एक्सेस दिनुभएका फोटो र भिडियोहरू चयन गर्नुहोस्"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"यो एप"</string> </resources> diff --git a/photopicker/res/values-nl/feature_privacy_explainer_strings.xml b/photopicker/res/values-nl/feature_privacy_explainer_strings.xml index 345f89e58..3c6bb3109 100644 --- a/photopicker/res/values-nl/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-nl/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> heeft alleen toegang tot de foto\'s die je selecteert"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Selecteer foto\'s en video\'s waartoe je <xliff:g id="APP_NAME">%1$s</xliff:g> toegang wilt geven"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Deze app"</string> </resources> diff --git a/photopicker/res/values-or/feature_privacy_explainer_strings.xml b/photopicker/res/values-or/feature_privacy_explainer_strings.xml index 8ee3b1f8c..69ce63f19 100644 --- a/photopicker/res/values-or/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-or/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> ପାଖରେ କେବଳ ଆପଣ ଚୟନ କରିଥିବା ଫଟୋଗୁଡ଼ିକର ଆକ୍ସେସ ରହିବ"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"ଆପଣ <xliff:g id="APP_NAME">%1$s</xliff:g>କୁ ଆକ୍ସେସ କରିବାକୁ ଅନୁମତି ଦେଇଥିବା ଫଟୋ ଏବଂ ଭିଡିଓଗୁଡ଼ିକୁ ଚୟନ କରନ୍ତୁ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"ଏହି ଆପ"</string> </resources> diff --git a/photopicker/res/values-pa/feature_privacy_explainer_strings.xml b/photopicker/res/values-pa/feature_privacy_explainer_strings.xml index f9358a6f0..1360579e6 100644 --- a/photopicker/res/values-pa/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-pa/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> ਕੋਲ ਸਿਰਫ਼ ਤੁਹਾਡੀਆਂ ਚੁਣੀਆਂ ਹੋਈਆਂ ਫ਼ੋਟੋਆਂ ਤੱਕ ਪਹੁੰਚ ਹੋਵੇਗੀ"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"ਉਹ ਫ਼ੋਟੋਆਂ ਅਤੇ ਵੀਡੀਓ ਚੁਣੋ, ਜਿਨ੍ਹਾਂ ਤੱਕ ਤੁਸੀਂ <xliff:g id="APP_NAME">%1$s</xliff:g> ਨੂੰ ਪਹੁੰਚ ਕਰਨ ਦੀ ਆਗਿਆ ਦੇਣੀ ਹੈ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"ਇਹ ਐਪ"</string> </resources> diff --git a/photopicker/res/values-pl/feature_privacy_explainer_strings.xml b/photopicker/res/values-pl/feature_privacy_explainer_strings.xml index b0d3add3e..30d8c603d 100644 --- a/photopicker/res/values-pl/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-pl/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Aplikacja <xliff:g id="APP_NAME">%1$s</xliff:g> ma dostęp tylko do wybranych przez Ciebie zdjęć"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Wybierz zdjęcia i filmy, które chcesz udostępnić aplikacji <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ta aplikacja"</string> </resources> diff --git a/photopicker/res/values-pt-rBR/feature_privacy_explainer_strings.xml b/photopicker/res/values-pt-rBR/feature_privacy_explainer_strings.xml index 097af5b89..dff23efb8 100644 --- a/photopicker/res/values-pt-rBR/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-pt-rBR/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"O app <xliff:g id="APP_NAME">%1$s</xliff:g> só terá acesso às fotos que você selecionar"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Selecione fotos e vídeos que o app <xliff:g id="APP_NAME">%1$s</xliff:g> pode acessar"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Este app"</string> </resources> diff --git a/photopicker/res/values-pt-rPT/feature_privacy_explainer_strings.xml b/photopicker/res/values-pt-rPT/feature_privacy_explainer_strings.xml index afb43db56..2ae133ada 100644 --- a/photopicker/res/values-pt-rPT/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-pt-rPT/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"A app <xliff:g id="APP_NAME">%1$s</xliff:g> só vai ter acesso às fotos selecionadas por si"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Selecione as fotos e os vídeos aos quais a app <xliff:g id="APP_NAME">%1$s</xliff:g> pode aceder"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Esta app"</string> </resources> diff --git a/photopicker/res/values-pt/feature_privacy_explainer_strings.xml b/photopicker/res/values-pt/feature_privacy_explainer_strings.xml index 097af5b89..dff23efb8 100644 --- a/photopicker/res/values-pt/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-pt/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"O app <xliff:g id="APP_NAME">%1$s</xliff:g> só terá acesso às fotos que você selecionar"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Selecione fotos e vídeos que o app <xliff:g id="APP_NAME">%1$s</xliff:g> pode acessar"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Este app"</string> </resources> diff --git a/photopicker/res/values-ro/feature_privacy_explainer_strings.xml b/photopicker/res/values-ro/feature_privacy_explainer_strings.xml index 1fb5f5bac..6161bc0a0 100644 --- a/photopicker/res/values-ro/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ro/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> poate accesa numai fotografiile selectate de tine"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Selectează fotografiile și videoclipurile la care <xliff:g id="APP_NAME">%1$s</xliff:g> poate avea acces"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Această aplicație"</string> </resources> diff --git a/photopicker/res/values-ru/feature_privacy_explainer_strings.xml b/photopicker/res/values-ru/feature_privacy_explainer_strings.xml index 7c2b3c74c..1af1259f5 100644 --- a/photopicker/res/values-ru/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ru/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> сможет получать доступ только к выбранным фотографиям."</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Выберите фотографии и видео, к которым <xliff:g id="APP_NAME">%1$s</xliff:g> может получить доступ."</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Это приложение"</string> </resources> diff --git a/photopicker/res/values-si/feature_privacy_explainer_strings.xml b/photopicker/res/values-si/feature_privacy_explainer_strings.xml index b43c88511..95012a92a 100644 --- a/photopicker/res/values-si/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-si/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> හට ඔබ තෝරන ඡායාරූප වලට පමණක් ප්රවේශ විය හැකි වනු ඇත"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"ඔබ <xliff:g id="APP_NAME">%1$s</xliff:g> හට ප්රවේශ වීමට ඉඩ දෙන ඡායාරූප සහ වීඩියෝ තෝරන්න"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"මෙම යෙදුම"</string> </resources> diff --git a/photopicker/res/values-sk/feature_privacy_explainer_strings.xml b/photopicker/res/values-sk/feature_privacy_explainer_strings.xml index 327db51d9..5002a8115 100644 --- a/photopicker/res/values-sk/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-sk/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Aplikácia <xliff:g id="APP_NAME">%1$s</xliff:g> bude mať prístup iba k fotkám, ktoré vyberiete"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Vyberte fotky a videá, ku ktorým má mať aplikácia <xliff:g id="APP_NAME">%1$s</xliff:g> prístup"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Táto aplikácia"</string> </resources> diff --git a/photopicker/res/values-sl/feature_privacy_explainer_strings.xml b/photopicker/res/values-sl/feature_privacy_explainer_strings.xml index 7a9ed4db4..7f776fe6f 100644 --- a/photopicker/res/values-sl/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-sl/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Aplikacija <xliff:g id="APP_NAME">%1$s</xliff:g> bo lahko dostopala samo do fotografij, ki jih izberete"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Izberite fotografije in videoposnetke, do katerih lahko dostopa aplikacija <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ta aplikacija"</string> </resources> diff --git a/photopicker/res/values-sq/feature_privacy_explainer_strings.xml b/photopicker/res/values-sq/feature_privacy_explainer_strings.xml index 754bf3f6f..39b07fa77 100644 --- a/photopicker/res/values-sq/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-sq/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"\"<xliff:g id="APP_NAME">%1$s</xliff:g>\" do të ketë qasje vetëm te fotografitë që zgjedh ti"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Zgjidh fotografitë dhe videot që lejon që të qaset \"<xliff:g id="APP_NAME">%1$s</xliff:g>\""</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ky aplikacion"</string> </resources> diff --git a/photopicker/res/values-sr/feature_privacy_explainer_strings.xml b/photopicker/res/values-sr/feature_privacy_explainer_strings.xml index 56e0cc428..874220706 100644 --- a/photopicker/res/values-sr/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-sr/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> ће имати приступ само сликама које изаберете"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Изаберите слике и видео снимке којима <xliff:g id="APP_NAME">%1$s</xliff:g> може да приступи"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ова апликација"</string> </resources> diff --git a/photopicker/res/values-sv/feature_privacy_explainer_strings.xml b/photopicker/res/values-sv/feature_privacy_explainer_strings.xml index 9304ec9e6..99ce01e52 100644 --- a/photopicker/res/values-sv/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-sv/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> får endast åtkomst till fotona du väljer"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Välj foton och videor som du ger <xliff:g id="APP_NAME">%1$s</xliff:g> åtkomst till"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Den här appen"</string> </resources> diff --git a/photopicker/res/values-sw/feature_privacy_explainer_strings.xml b/photopicker/res/values-sw/feature_privacy_explainer_strings.xml index d57b030ec..f0df75553 100644 --- a/photopicker/res/values-sw/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-sw/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> itaweza tu kufikia picha utakazochagua"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Chagua picha na video unazoiruhusu <xliff:g id="APP_NAME">%1$s</xliff:g> kufikia"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Programu hii"</string> </resources> diff --git a/photopicker/res/values-ta/feature_privacy_explainer_strings.xml b/photopicker/res/values-ta/feature_privacy_explainer_strings.xml index a742ce013..9c94c2b67 100644 --- a/photopicker/res/values-ta/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ta/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"நீங்கள் தேர்ந்தெடுக்கும் படங்களை மட்டுமே <xliff:g id="APP_NAME">%1$s</xliff:g> ஆப்ஸால் அணுக முடியும்"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> ஆப்ஸ் அணுகுவதற்கு நீங்கள் அனுமதியளிக்கும் படங்களையும் வீடியோக்களையும் தேர்ந்தெடுங்கள்"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"இந்த ஆப்ஸ்"</string> </resources> diff --git a/photopicker/res/values-te/feature_privacy_explainer_strings.xml b/photopicker/res/values-te/feature_privacy_explainer_strings.xml index 9e75f9a4b..b2e682385 100644 --- a/photopicker/res/values-te/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-te/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g>కు మీరు ఎంచుకునే ఫోటోలకు మాత్రమే యాక్సెస్ ఉంటుంది"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> యాక్సెస్ చేయడానికి మీరు అనుమతించే ఫోటోలను, వీడియోలను ఎంచుకోండి"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"ఈ యాప్"</string> </resources> diff --git a/photopicker/res/values-th/feature_privacy_explainer_strings.xml b/photopicker/res/values-th/feature_privacy_explainer_strings.xml index ad8cb30cf..5cac14b42 100644 --- a/photopicker/res/values-th/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-th/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> จะเข้าถึงได้เฉพาะรูปภาพที่คุณเลือกเท่านั้น"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"เลือกรูปภาพและวิดีโอที่คุณอนุญาตให้<xliff:g id="APP_NAME">%1$s</xliff:g>เข้าถึง"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"แอปนี้"</string> </resources> diff --git a/photopicker/res/values-tl/feature_privacy_explainer_strings.xml b/photopicker/res/values-tl/feature_privacy_explainer_strings.xml index 7e53c88ea..6b0951428 100644 --- a/photopicker/res/values-tl/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-tl/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Magkakaroon lang ng access ang <xliff:g id="APP_NAME">%1$s</xliff:g> sa mga larawang pipiliin mo"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Pumili ng mga larawan at video na pinapayagan mong ma-access ng <xliff:g id="APP_NAME">%1$s</xliff:g>"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"App na ito"</string> </resources> diff --git a/photopicker/res/values-tr/feature_privacy_explainer_strings.xml b/photopicker/res/values-tr/feature_privacy_explainer_strings.xml index 74afb1779..55b72df58 100644 --- a/photopicker/res/values-tr/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-tr/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> yalnızca seçtiğiniz fotoğraflara erişebilir"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> uygulamasının erişmesine izin verdiğiniz fotoğraf ve videoları seçin"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Bu uygulama"</string> </resources> diff --git a/photopicker/res/values-uk/feature_privacy_explainer_strings.xml b/photopicker/res/values-uk/feature_privacy_explainer_strings.xml index a22c8cfdf..2d03b7ccd 100644 --- a/photopicker/res/values-uk/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-uk/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"Додаток <xliff:g id="APP_NAME">%1$s</xliff:g> матиме доступ лише до вибраних вами фотографій"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Виберіть фотографії і відео, до яких додаток <xliff:g id="APP_NAME">%1$s</xliff:g> зможе отримувати доступ"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Цей додаток"</string> </resources> diff --git a/photopicker/res/values-ur/feature_privacy_explainer_strings.xml b/photopicker/res/values-ur/feature_privacy_explainer_strings.xml index 9d00a5212..703c8cc08 100644 --- a/photopicker/res/values-ur/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-ur/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> صرف آپ کے منتخب کردہ تصاویر تک رسائی حاصل کرے گی"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"وہ تصاویر اور ویڈیوز منتخب کریں جن تک آپ <xliff:g id="APP_NAME">%1$s</xliff:g> کو رسائی حاصل کرنے کی اجازت دیتے ہیں"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"یہ ایپ"</string> </resources> diff --git a/photopicker/res/values-uz/feature_privacy_explainer_strings.xml b/photopicker/res/values-uz/feature_privacy_explainer_strings.xml index 8de1375b0..0866c72a9 100644 --- a/photopicker/res/values-uz/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-uz/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> faqat siz tanlagan suratlarga kira oladi"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"<xliff:g id="APP_NAME">%1$s</xliff:g> ilovasi kira olishi uchun video va suratlarni tanlang"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Bu ilova"</string> </resources> diff --git a/photopicker/res/values-vi/feature_privacy_explainer_strings.xml b/photopicker/res/values-vi/feature_privacy_explainer_strings.xml index cc470c4b0..f36f67b59 100644 --- a/photopicker/res/values-vi/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-vi/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"<xliff:g id="APP_NAME">%1$s</xliff:g> sẽ chỉ có quyền truy cập vào những ảnh bạn chọn"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Chọn những ảnh và video mà bạn cho phép <xliff:g id="APP_NAME">%1$s</xliff:g> truy cập"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Ứng dụng này"</string> </resources> diff --git a/photopicker/res/values-zh-rCN/feature_privacy_explainer_strings.xml b/photopicker/res/values-zh-rCN/feature_privacy_explainer_strings.xml index ce0ec7759..22e39a2f2 100644 --- a/photopicker/res/values-zh-rCN/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-zh-rCN/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"“<xliff:g id="APP_NAME">%1$s</xliff:g>”仅有权访问您选择的照片"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"选择您允许“<xliff:g id="APP_NAME">%1$s</xliff:g>”访问的照片和视频"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"此应用"</string> </resources> diff --git a/photopicker/res/values-zh-rHK/feature_privacy_explainer_strings.xml b/photopicker/res/values-zh-rHK/feature_privacy_explainer_strings.xml index 473ad61a8..0130ebc7a 100644 --- a/photopicker/res/values-zh-rHK/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-zh-rHK/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"「<xliff:g id="APP_NAME">%1$s</xliff:g>」只可存取你選取的相片"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"選取允許「<xliff:g id="APP_NAME">%1$s</xliff:g>」存取的相片和影片"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"此應用程式"</string> </resources> diff --git a/photopicker/res/values-zh-rTW/feature_privacy_explainer_strings.xml b/photopicker/res/values-zh-rTW/feature_privacy_explainer_strings.xml index 4d71eb132..c7bf1c219 100644 --- a/photopicker/res/values-zh-rTW/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-zh-rTW/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"「<xliff:g id="APP_NAME">%1$s</xliff:g>」只能存取你選取的相片"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"請選取允許「<xliff:g id="APP_NAME">%1$s</xliff:g>」存取的相片和影片"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"這個應用程式"</string> </resources> diff --git a/photopicker/res/values-zu/feature_privacy_explainer_strings.xml b/photopicker/res/values-zu/feature_privacy_explainer_strings.xml index 35224e752..a79a1dc11 100644 --- a/photopicker/res/values-zu/feature_privacy_explainer_strings.xml +++ b/photopicker/res/values-zu/feature_privacy_explainer_strings.xml @@ -18,6 +18,7 @@ <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="photopicker_privacy_explainer" msgid="4607406928962678061">"I-<xliff:g id="APP_NAME">%1$s</xliff:g> izokwazi ukufinyelela kuphela izithombe ozikhethayo"</string> - <string name="photopicker_privacy_explainer_permission_mode" msgid="2329456210808751436">"Khetha izithombe namavidiyo avumela i-<xliff:g id="APP_NAME">%1$s</xliff:g> ukuba ifinyelele"</string> + <!-- no translation found for photopicker_privacy_explainer_permission_mode (6875197264505249268) --> + <skip /> <string name="photopicker_privacy_explainer_generic_app_name" msgid="765602231395858098">"Le app"</string> </resources> diff --git a/photopicker/src/com/android/photopicker/data/DataServiceImpl.kt b/photopicker/src/com/android/photopicker/data/DataServiceImpl.kt index c413b7f73..a7537b004 100644 --- a/photopicker/src/com/android/photopicker/data/DataServiceImpl.kt +++ b/photopicker/src/com/android/photopicker/data/DataServiceImpl.kt @@ -426,7 +426,7 @@ class DataServiceImpl( Log.v( DataService.TAG, - "Created an album media paging source that queries " + "$availableProviders", + "Created an album media paging source that queries $availableProviders", ) albumMap[album.id] = albumMediaPagingSource @@ -454,7 +454,7 @@ class DataServiceImpl( Log.v( DataService.TAG, - "Created an album paging source that queries " + "$availableProviders", + "Created an album paging source that queries $availableProviders", ) albumPagingSources.add(albumPagingSource) @@ -512,7 +512,7 @@ class DataServiceImpl( Log.v( DataService.TAG, - "Created a media paging source that queries database for" + "preview items.", + "Created a media paging source that queries database for preview items.", ) mediaPagingSources.add(mediaPagingSource) mediaPagingSource diff --git a/photopicker/src/com/android/photopicker/data/MediaProviderClient.kt b/photopicker/src/com/android/photopicker/data/MediaProviderClient.kt index 4733523dc..d0c873e84 100644 --- a/photopicker/src/com/android/photopicker/data/MediaProviderClient.kt +++ b/photopicker/src/com/android/photopicker/data/MediaProviderClient.kt @@ -29,6 +29,9 @@ import com.android.modules.utils.build.SdkLevel import com.android.photopicker.core.configuration.PhotopickerConfiguration import com.android.photopicker.data.model.CollectionInfo import com.android.photopicker.data.model.Group +import com.android.photopicker.data.model.GroupPageKey +import com.android.photopicker.data.model.Icon +import com.android.photopicker.data.model.KeyToCategoryType import com.android.photopicker.data.model.Media import com.android.photopicker.data.model.MediaPageKey import com.android.photopicker.data.model.MediaSource @@ -39,15 +42,16 @@ import com.android.photopicker.features.search.model.SearchSuggestion import com.android.photopicker.features.search.model.SearchSuggestionType /** - * A client class that is reponsible for holding logic required to interact with [MediaProvider]. + * A client class that is responsible for holding logic required to interact with [MediaProvider]. * * It typically fetches data from [MediaProvider] using content queries and call methods. */ open class MediaProviderClient { companion object { private const val TAG = "MediaProviderClient" - private const val MEDIA_INIT_CALL_METHOD: String = "picker_media_init" - private const val SEARCH_REQUEST_INIT_CALL_METHOD = "picker_internal_search_media_init" + private const val MEDIA_SETS_INIT_CALL_METHOD: String = "picker_media_sets_init_call" + private const val MEDIA_SET_CONTENTS_INIT_CALL_METHOD: String = + "picker_media_in_media_set_init" private const val EXTRA_MIME_TYPES = "mime_types" private const val EXTRA_INTENT_ACTION = "intent_action" private const val EXTRA_PROVIDERS = "providers" @@ -56,6 +60,10 @@ open class MediaProviderClient { private const val EXTRA_ALBUM_AUTHORITY = "album_authority" private const val COLUMN_GRANTS_COUNT = "grants_count" private const val PRE_SELECTION_URIS = "pre_selection_uris" + const val MEDIA_INIT_CALL_METHOD: String = "picker_media_init" + const val SEARCH_REQUEST_INIT_CALL_METHOD = "picker_internal_search_media_init" + const val GET_SEARCH_PROVIDERS_CALL_METHOD = "picker_internal_get_search_providers" + const val SEARCH_PROVIDER_AUTHORITIES = "search_provider_authorities" const val SEARCH_REQUEST_ID = "search_request_id" } @@ -76,6 +84,32 @@ open class MediaProviderClient { } /** + * Contains all mandatory keys required to make a Category and Album query that are not present + * in [MediaQuery] already. + */ + private enum class CategoryAndAlbumQuery(val key: String) { + PARENT_CATEGORY_ID("parent_category_id") + } + + /** + * Contains all mandatory keys required to make a Media Set query that are not present in + * [MediaQuery] already. + */ + private enum class MediaSetsQuery(val key: String) { + PARENT_CATEGORY_ID("parent_category_id"), + PARENT_CATEGORY_AUTHORITY("parent_category_authority"), + } + + /** + * Contains all mandatory keys required to make a Media Set contents query that are not present + * in [MediaQuery] already. + */ + private enum class MediaSetContentsQuery(val key: String) { + PARENT_MEDIA_SET_PICKER_ID("media_set_picker_id"), + PARENT_MEDIA_SET_AUTHORITY("media_set_picker_authority"), + } + + /** * Contains all optional and mandatory keys for data in the Available Providers query response. */ enum class AvailableProviderResponse(val key: String) { @@ -156,6 +190,28 @@ open class MediaProviderClient { SUGGESTION_TYPE("suggestion_type"), } + enum class GroupResponse(val key: String) { + MEDIA_GROUP("media_group"), + /** Identifier received from CMP. This cannot be null. */ + GROUP_ID("group_id"), + /** Identifier used in Picker Backend, if any. */ + PICKER_ID("picker_id"), + DISPLAY_NAME("display_name"), + AUTHORITY("authority"), + UNWRAPPED_COVER_URI("unwrapped_cover_uri"), + ADDITIONAL_UNWRAPPED_COVER_URI_1("additional_cover_uri_1"), + ADDITIONAL_UNWRAPPED_COVER_URI_2("additional_cover_uri_2"), + ADDITIONAL_UNWRAPPED_COVER_URI_3("additional_cover_uri_3"), + CATEGORY_TYPE("category_type"), + IS_LEAF_CATEGORY("is_leaf_category"), + } + + enum class GroupType() { + CATEGORY, + MEDIA_SET, + ALBUM, + } + /** Fetch available [Provider]-s from the Media Provider process. */ fun fetchAvailableProviders(contentResolver: ContentResolver): List<Provider> { try { @@ -225,8 +281,8 @@ open class MediaProviderClient { cursor?.let { LoadResult.Page( data = cursor.getListOfMedia(), - prevKey = cursor.getPrevPageKey(), - nextKey = cursor.getNextPageKey(), + prevKey = cursor.getPrevMediaPageKey(), + nextKey = cursor.getNextMediaPageKey(), itemsBefore = cursor.getItemsBeforeCount() ?: LoadResult.Page.COUNT_UNDEFINED, ) @@ -276,8 +332,8 @@ open class MediaProviderClient { cursor?.let { LoadResult.Page( data = cursor.getListOfMedia(), - prevKey = cursor.getPrevPageKey(), - nextKey = cursor.getNextPageKey(), + prevKey = cursor.getPrevMediaPageKey(), + nextKey = cursor.getNextMediaPageKey(), itemsBefore = cursor.getItemsBeforeCount() ?: LoadResult.Page.COUNT_UNDEFINED, ) @@ -331,8 +387,8 @@ open class MediaProviderClient { cursor?.let { LoadResult.Page( data = cursor.getListOfMedia(), - prevKey = cursor.getPrevPageKey(), - nextKey = cursor.getNextPageKey(), + prevKey = cursor.getPrevMediaPageKey(), + nextKey = cursor.getNextMediaPageKey(), ) } ?: throw IllegalStateException( @@ -377,8 +433,8 @@ open class MediaProviderClient { cursor?.let { LoadResult.Page( data = cursor.getListOfAlbums(), - prevKey = cursor.getPrevPageKey(), - nextKey = cursor.getNextPageKey(), + prevKey = cursor.getPrevMediaPageKey(), + nextKey = cursor.getNextMediaPageKey(), ) } ?: throw IllegalStateException( @@ -427,8 +483,8 @@ open class MediaProviderClient { cursor?.let { LoadResult.Page( data = cursor.getListOfMedia(), - prevKey = cursor.getPrevPageKey(), - nextKey = cursor.getNextPageKey(), + prevKey = cursor.getPrevMediaPageKey(), + nextKey = cursor.getNextMediaPageKey(), ) } ?: throw IllegalStateException( @@ -542,6 +598,9 @@ open class MediaProviderClient { } } + /** + * Fetches a list of search suggestions from MediaProvider filtered by the input prefix string. + */ suspend fun fetchSearchSuggestions( resolver: ContentResolver, prefix: String, @@ -571,6 +630,157 @@ open class MediaProviderClient { } /** + * Fetches a list of categories and albums from MediaProvider filtered by the input list of + * available providers, mime types and parent category id. + */ + suspend fun fetchCategoriesAndAlbums( + pageKey: GroupPageKey, + pageSize: Int, + contentResolver: ContentResolver, + availableProviders: List<Provider>, + parentCategoryId: String?, + config: PhotopickerConfiguration, + cancellationSignal: CancellationSignal?, + ): LoadResult<GroupPageKey, Group> { + val input: Bundle = + bundleOf( + MediaQuery.PICKER_ID.key to pageKey.pickerId, + MediaQuery.PAGE_SIZE.key to pageSize, + MediaQuery.PROVIDERS.key to + ArrayList<String>().apply { + availableProviders.forEach { provider -> add(provider.authority) } + }, + EXTRA_MIME_TYPES to config.mimeTypes, + EXTRA_INTENT_ACTION to config.action, + CategoryAndAlbumQuery.PARENT_CATEGORY_ID.key to parentCategoryId, + ) + try { + return contentResolver + .query( + getCategoryUri(parentCategoryId), + /* projection */ null, + input, + cancellationSignal, + ) + .use { cursor -> + cursor?.let { + LoadResult.Page( + data = cursor.getListOfCategoriesAndAlbums(availableProviders), + prevKey = cursor.getPrevGroupPageKey(), + nextKey = cursor.getNextGroupPageKey(), + ) + } + ?: throw IllegalStateException( + "Received a null response from Content Provider" + ) + } + } catch (e: RuntimeException) { + throw RuntimeException( + "Could not fetch categories and albums for parent category $parentCategoryId", + e, + ) + } + } + + /** + * Fetches a list of media sets from MediaProvider filtered by the input list of available + * providers, mime types and parent category id. + */ + suspend fun fetchMediaSets( + pageKey: GroupPageKey, + pageSize: Int, + contentResolver: ContentResolver, + availableProviders: List<Provider>, + parentCategory: Group.Category, + config: PhotopickerConfiguration, + cancellationSignal: CancellationSignal?, + ): LoadResult<GroupPageKey, Group.MediaSet> { + val input: Bundle = + bundleOf( + MediaQuery.PICKER_ID.key to pageKey.pickerId, + MediaQuery.PAGE_SIZE.key to pageSize, + MediaQuery.PROVIDERS.key to + ArrayList<String>().apply { + availableProviders.forEach { provider -> add(provider.authority) } + }, + EXTRA_MIME_TYPES to config.mimeTypes, + EXTRA_INTENT_ACTION to config.action, + MediaSetsQuery.PARENT_CATEGORY_ID.key to parentCategory.id, + MediaSetsQuery.PARENT_CATEGORY_AUTHORITY.key to parentCategory.authority, + ) + try { + return contentResolver + .query(MEDIA_SETS_URI, /* projection */ null, input, cancellationSignal) + .use { cursor -> + cursor?.let { + LoadResult.Page( + data = cursor.getListOfMediaSets(availableProviders), + prevKey = cursor.getPrevGroupPageKey(), + nextKey = cursor.getNextGroupPageKey(), + ) + } + ?: throw IllegalStateException( + "Received a null response from Content Provider" + ) + } + } catch (e: RuntimeException) { + throw RuntimeException( + "Could not fetch media sets for parent category ${parentCategory.id}", + e, + ) + } + } + + /** + * Fetches a list of media items in a media set from MediaProvider filtered by the input list of + * available providers, mime types and parent media set id. + */ + suspend fun fetchMediaSetContents( + pageKey: MediaPageKey, + pageSize: Int, + contentResolver: ContentResolver, + availableProviders: List<Provider>, + parentMediaSet: Group.MediaSet, + config: PhotopickerConfiguration, + cancellationSignal: CancellationSignal?, + ): LoadResult<MediaPageKey, Media> { + val input: Bundle = + bundleOf( + MediaQuery.PICKER_ID.key to pageKey.pickerId, + MediaQuery.PAGE_SIZE.key to pageSize, + MediaQuery.PROVIDERS.key to + ArrayList<String>().apply { + availableProviders.forEach { provider -> add(provider.authority) } + }, + EXTRA_MIME_TYPES to config.mimeTypes, + EXTRA_INTENT_ACTION to config.action, + MediaSetContentsQuery.PARENT_MEDIA_SET_PICKER_ID.key to parentMediaSet.pickerId, + MediaSetContentsQuery.PARENT_MEDIA_SET_AUTHORITY.key to parentMediaSet.authority, + ) + try { + return contentResolver + .query(MEDIA_SET_CONTENTS_URI, /* projection */ null, input, cancellationSignal) + .use { cursor -> + cursor?.let { + LoadResult.Page( + data = cursor.getListOfMedia(), + prevKey = cursor.getPrevMediaPageKey(), + nextKey = cursor.getNextMediaPageKey(), + ) + } + ?: throw IllegalStateException( + "Received a null response from Content Provider" + ) + } + } catch (e: RuntimeException) { + throw RuntimeException( + "Could not fetch media set contents for parent media set ${parentMediaSet.id}", + e, + ) + } + } + + /** * Send a refresh media request to MediaProvider. This is a signal for MediaProvider to refresh * its cache, if required. */ @@ -617,6 +827,67 @@ open class MediaProviderClient { } /** + * Send a refresh media sets request to MediaProvider. This is a signal for MediaProvider to + * refresh its cache for the given parent category id and authority, if required. + */ + suspend fun refreshMediaSets( + contentResolver: ContentResolver, + category: Group.Category, + config: PhotopickerConfiguration, + ) { + val extras = + bundleOf( + EXTRA_MIME_TYPES to config.mimeTypes, + MediaSetsQuery.PARENT_CATEGORY_ID.key to category.id, + MediaSetsQuery.PARENT_CATEGORY_AUTHORITY.key to category.authority, + ) + + try { + contentResolver.call( + MEDIA_PROVIDER_AUTHORITY, + MEDIA_SETS_INIT_CALL_METHOD, + /* arg */ null, + extras, + ) + } catch (e: RuntimeException) { + Log.e(TAG, "Could not send refresh media sets call to Media Provider $extras", e) + } + } + + /** + * Send a refresh media set contents request to MediaProvider. This is a signal for + * MediaProvider to refresh its cache for the given parent media set id and authority, if + * required. + */ + suspend fun refreshMediaSetContents( + contentResolver: ContentResolver, + mediaSet: Group.MediaSet, + config: PhotopickerConfiguration, + ) { + val extras = + bundleOf( + EXTRA_MIME_TYPES to config.mimeTypes, + MediaSetContentsQuery.PARENT_MEDIA_SET_PICKER_ID.key to mediaSet.id, + MediaSetContentsQuery.PARENT_MEDIA_SET_AUTHORITY.key to mediaSet.authority, + ) + + try { + contentResolver.call( + MEDIA_PROVIDER_AUTHORITY, + MEDIA_SET_CONTENTS_INIT_CALL_METHOD, + /* arg */ null, + extras, + ) + } catch (e: RuntimeException) { + Log.e( + TAG, + "Could not send refresh media set contents call to Media Provider $extras", + e, + ) + } + } + + /** * Creates a search request with the data source. * * The data source is expected to return a search request id associated with the request. @@ -678,6 +949,43 @@ open class MediaProviderClient { } } + /** + * Get available search providers from the Media Provider client using the available + * [ContentResolver]. + * + * If the available providers are known at the time of the query, this method will filter the + * results of the call so that search providers are a subset of the available providers. + * + * @param resolver The [ContentResolver] that resolves to the desired instance of MediaProvider. + * (This may resolve in a cross profile instance of MediaProvider). + * @param availableProviders + */ + suspend fun fetchSearchProviderAuthorities( + resolver: ContentResolver, + availableProviders: List<Provider>? = null, + ): List<String>? { + try { + val availableProviderAuthorities: Set<String>? = + availableProviders?.map { it.authority }?.toSet() + val result: Bundle? = + resolver.call( + MEDIA_PROVIDER_AUTHORITY, + GET_SEARCH_PROVIDERS_CALL_METHOD, + /* arg */ null, + /* extras */ null, + ) + return result?.getStringArrayList(SEARCH_PROVIDER_AUTHORITIES)?.filter { + availableProviderAuthorities?.contains(it) ?: true + } + } catch (e: RuntimeException) { + // If we can't fetch the available providers, basic functionality of photopicker does + // not work. In order to catch this earlier in testing, throw an error instead of + // silencing it. + Log.e(TAG, "Could not fetch providers with search enabled", e) + return null + } + } + /** Creates a list of [Provider] from the given [Cursor]. */ private fun getListOfProviders(cursor: Cursor): List<Provider> { val result: MutableList<Provider> = mutableListOf<Provider>() @@ -835,10 +1143,10 @@ open class MediaProviderClient { } /** - * Extracts the previous page key from the given [Cursor]. In case the cursor contains the + * Extracts the previous media page key from the given [Cursor]. In case the cursor contains the * contents of the first page, the previous page key will be null. */ - private fun Cursor.getPrevPageKey(): MediaPageKey? { + private fun Cursor.getPrevMediaPageKey(): MediaPageKey? { val id: Long = extras.getLong(MediaResponseExtras.PREV_PAGE_ID.key, Long.MIN_VALUE) val date: Long = extras.getLong(MediaResponseExtras.PREV_PAGE_DATE_TAKEN.key, Long.MIN_VALUE) @@ -850,10 +1158,10 @@ open class MediaProviderClient { } /** - * Extracts the next page key from the given [Cursor]. In case the cursor contains the contents - * of the last page, the next page key will be null. + * Extracts the next media page key from the given [Cursor]. In case the cursor contains the + * contents of the last page, the next page key will be null. */ - private fun Cursor.getNextPageKey(): MediaPageKey? { + private fun Cursor.getNextMediaPageKey(): MediaPageKey? { val id: Long = extras.getLong(MediaResponseExtras.NEXT_PAGE_ID.key, Long.MIN_VALUE) val date: Long = extras.getLong(MediaResponseExtras.NEXT_PAGE_DATE_TAKEN.key, Long.MIN_VALUE) @@ -865,6 +1173,32 @@ open class MediaProviderClient { } /** + * Extracts the previous group page key from the given [Cursor]. In case the cursor contains the + * contents of the first page, the previous page key will be null. + */ + private fun Cursor.getPrevGroupPageKey(): GroupPageKey? { + val id: Long = extras.getLong(MediaResponseExtras.PREV_PAGE_ID.key, Long.MIN_VALUE) + return if (id == Long.MIN_VALUE) { + null + } else { + GroupPageKey(pickerId = id) + } + } + + /** + * Extracts the next group page key from the given [Cursor]. In case the cursor contains the + * contents of the last page, the next page key will be null. + */ + private fun Cursor.getNextGroupPageKey(): GroupPageKey? { + val id: Long = extras.getLong(MediaResponseExtras.NEXT_PAGE_ID.key, Long.MAX_VALUE) + return if (id == Long.MAX_VALUE) { + null + } else { + GroupPageKey(pickerId = id) + } + } + + /** * Extracts the before items count from the given [Cursor]. In case the cursor does not contain * this value, return null. */ @@ -882,6 +1216,8 @@ open class MediaProviderClient { if (this.moveToFirst()) { do { val albumId = getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_ID.key)) + val coverUriString = + getString(getColumnIndexOrThrow(AlbumResponse.UNWRAPPED_COVER_URI.key)) result.add( Group.Album( id = albumId, @@ -892,12 +1228,7 @@ open class MediaProviderClient { getLong(getColumnIndexOrThrow(AlbumResponse.DATE_TAKEN.key)), displayName = getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_NAME.key)), - coverUri = - Uri.parse( - getString( - getColumnIndexOrThrow(AlbumResponse.UNWRAPPED_COVER_URI.key) - ) - ), + coverUri = coverUriString?.let { Uri.parse(it) } ?: Uri.parse(""), coverMediaSource = MediaSource.valueOf( getString( @@ -959,6 +1290,183 @@ open class MediaProviderClient { return result } + /** Creates a list of [Group.Category]-s and [Group.Album]-s from the given [Cursor]. */ + private fun Cursor.getListOfCategoriesAndAlbums( + availableProviders: List<Provider> + ): List<Group> { + val result: MutableList<Group> = mutableListOf<Group>() + val authorityToSourceMap: Map<String, MediaSource> = + availableProviders.associate { provider -> provider.authority to provider.mediaSource } + + if (this.moveToFirst()) { + do { + try { + val groupType = getString(getColumnIndexOrThrow(GroupResponse.MEDIA_GROUP.key)) + when (groupType) { + GroupType.CATEGORY.name -> { + val icons: List<Icon> = + listOf<Icon?>( + this.getIcon( + authorityToSourceMap, + GroupResponse.UNWRAPPED_COVER_URI.key, + ), + this.getIcon( + authorityToSourceMap, + GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_1.key, + ), + this.getIcon( + authorityToSourceMap, + GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_2.key, + ), + this.getIcon( + authorityToSourceMap, + GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_3.key, + ), + ) + .filterNotNull() + + result.add( + Group.Category( + id = + getString( + getColumnIndexOrThrow(GroupResponse.GROUP_ID.key) + ), + pickerId = + getLong(getColumnIndexOrThrow(GroupResponse.PICKER_ID.key)), + authority = + getString( + getColumnIndexOrThrow(GroupResponse.AUTHORITY.key) + ), + displayName = + getString( + getColumnIndexOrThrow(GroupResponse.DISPLAY_NAME.key) + ), + categoryType = + KeyToCategoryType[ + getString( + getColumnIndexOrThrow( + GroupResponse.CATEGORY_TYPE.key + ) + )] + ?: throw IllegalArgumentException( + "Could not recognize category type" + ), + icons = icons, + isLeafCategory = + getInt( + getColumnIndexOrThrow( + GroupResponse.IS_LEAF_CATEGORY.key + ) + ) == 1, + ) + ) + } + + GroupType.ALBUM.name -> { + val coverUriString = + getString( + getColumnIndexOrThrow(GroupResponse.UNWRAPPED_COVER_URI.key) + ) + val coverUri = coverUriString?.let { Uri.parse(it) } ?: Uri.parse("") + + result.add( + Group.Album( + id = + getString( + getColumnIndexOrThrow(GroupResponse.GROUP_ID.key) + ), + pickerId = + getLong(getColumnIndexOrThrow(GroupResponse.PICKER_ID.key)), + authority = + getString( + getColumnIndexOrThrow(GroupResponse.AUTHORITY.key) + ), + dateTakenMillisLong = + Long.MAX_VALUE, // This is not used and will soon be + // obsolete + displayName = + getString( + getColumnIndexOrThrow(GroupResponse.DISPLAY_NAME.key) + ), + coverUri = coverUri, + coverMediaSource = + coverUri?.let { + authorityToSourceMap[coverUri.getAuthority()] + } ?: MediaSource.LOCAL, + ) + ) + } + + else -> { + Log.w(TAG, "Invalid group type: $groupType") + } + } + } catch (e: RuntimeException) { + Log.w(TAG, "Could not extract category or album from cursor, skipping it", e) + } + } while (moveToNext()) + } + + return result + } + + /** Creates a list of [Group.MediaSet]-s from the given [Cursor]. */ + private fun Cursor.getListOfMediaSets( + availableProviders: List<Provider> + ): List<Group.MediaSet> { + val result: MutableList<Group.MediaSet> = mutableListOf<Group.MediaSet>() + val authorityToSourceMap: Map<String, MediaSource> = + availableProviders.associate { provider -> provider.authority to provider.mediaSource } + + if (this.moveToFirst()) { + do { + try { + result.add( + Group.MediaSet( + id = getString(getColumnIndexOrThrow(GroupResponse.GROUP_ID.key)), + pickerId = getLong(getColumnIndexOrThrow(GroupResponse.PICKER_ID.key)), + authority = + getString(getColumnIndexOrThrow(GroupResponse.AUTHORITY.key)), + displayName = + getString(getColumnIndexOrThrow(GroupResponse.DISPLAY_NAME.key)), + icon = + this.getIcon( + authorityToSourceMap, + GroupResponse.UNWRAPPED_COVER_URI.key, + ) ?: Icon(uri = Uri.parse(""), mediaSource = MediaSource.LOCAL), + ) + ) + } catch (e: RuntimeException) { + Log.w(TAG, "Could not extract media set from cursor, skipping it", e) + } + } while (moveToNext()) + } + + return result + } + + /** Creates an [Icon] object from the current [Cursor] row. If an error occurs, returns null. */ + private fun Cursor.getIcon( + authorityToSourceMap: Map<String, MediaSource>, + columnName: String, + ): Icon? { + var unwrappedUriString: String? = null + + try { + unwrappedUriString = getString(getColumnIndexOrThrow(columnName)) + } catch (e: RuntimeException) { + Log.e(TAG, "Could not get unwrapped uri $unwrappedUriString from cursor", e) + } + + return unwrappedUriString?.let { + val unwrappedUri: Uri = Uri.parse(unwrappedUriString) + val authority: String? = unwrappedUri.getAuthority() + val mediaSource: MediaSource = authorityToSourceMap[authority] ?: MediaSource.LOCAL + val icon = Icon(unwrappedUri, mediaSource) + icon + } + } + /** Convert the input search suggestion type string to enum */ private fun getSearchSuggestionType(stringSuggestionType: String?): SearchSuggestionType { requireNotNull(stringSuggestionType) { "Suggestion type is null" } diff --git a/photopicker/src/com/android/photopicker/data/PrefetchDataService.kt b/photopicker/src/com/android/photopicker/data/PrefetchDataService.kt index a23b139a2..a151b70b4 100644 --- a/photopicker/src/com/android/photopicker/data/PrefetchDataService.kt +++ b/photopicker/src/com/android/photopicker/data/PrefetchDataService.kt @@ -16,7 +16,7 @@ package com.android.photopicker.data -import com.android.photopicker.features.search.model.SearchEnabledState +import com.android.photopicker.features.search.model.GlobalSearchState /** Class responsible to fetch all the required data before feature initialization */ interface PrefetchDataService { @@ -24,5 +24,9 @@ interface PrefetchDataService { val TAG: String = "PrefetchDataService" } - suspend fun getSearchState(): SearchEnabledState + /** + * Get the global search state from the Data Source. The global search state refers to the + * search state of all providers in all user profiles. + */ + suspend fun getGlobalSearchState(): GlobalSearchState } diff --git a/photopicker/src/com/android/photopicker/data/PrefetchDataServiceImpl.kt b/photopicker/src/com/android/photopicker/data/PrefetchDataServiceImpl.kt index 2d2044ba5..fada902ce 100644 --- a/photopicker/src/com/android/photopicker/data/PrefetchDataServiceImpl.kt +++ b/photopicker/src/com/android/photopicker/data/PrefetchDataServiceImpl.kt @@ -16,11 +16,76 @@ package com.android.photopicker.data -import com.android.photopicker.features.search.model.SearchEnabledState +import android.content.Context +import android.util.Log +import com.android.photopicker.core.user.UserMonitor +import com.android.photopicker.core.user.UserProfile +import com.android.photopicker.features.search.model.GlobalSearchState +import com.android.photopicker.features.search.model.GlobalSearchStateInfo +import com.android.photopicker.util.mapOfDeferredWithTimeout +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Deferred -class PrefetchDataServiceImpl() : PrefetchDataService { +/** Implementation of [PrefetchDataService] that typically fetches data from MediaProvider. */ +class PrefetchDataServiceImpl( + val mediaProviderClient: MediaProviderClient, + val userMonitor: UserMonitor, + val context: Context, + val dispatcher: CoroutineDispatcher, +) : PrefetchDataService { - override suspend fun getSearchState(): SearchEnabledState { - return SearchEnabledState.DISABLED + override suspend fun getGlobalSearchState(): GlobalSearchState { + // Create a map of user id to lambda that fetches search provider authorities for that + // user. + val inputMap: Map<Int, suspend (MediaProviderClient) -> Any?> = + userMonitor.userStatus.value.allProfiles + .map { profile: UserProfile -> + val lambda: suspend (MediaProviderClient) -> Any? = + { mediaProviderClient: MediaProviderClient -> + mediaProviderClient.fetchSearchProviderAuthorities( + context + .createPackageContextAsUser( + context.packageName, /* flags */ + 0, + profile.handle, + ) + .contentResolver + ) + } + profile.identifier to lambda + } + .toMap() + + // Get a map of user id to Deferred task that fetches search provider authorities for + // that user in parallel with a timeout. + val deferredMap: Map<Int, Deferred<Any?>> = + mapOfDeferredWithTimeout( + inputMap = inputMap, + input = mediaProviderClient, + timeoutMillis = 100L, + ) + + // Await all the deferred tasks and create a map of user id to the search provider + // authorities. + @Suppress("UNCHECKED_CAST") + val globalSearchProviders: Map<Int, List<String>?> = + deferredMap + .map { + val searchProviders: Any? = it.value.await() + it.key to if (searchProviders is List<*>?) searchProviders else null + } + .toMap() as Map<Int, List<String>?> + + val globalSearchStateInfo = + GlobalSearchStateInfo( + globalSearchProviders, + userMonitor.userStatus.value.activeUserProfile.identifier, + ) + Log.d( + PrefetchDataService.TAG, + "Global search providers available are $globalSearchProviders. " + + "Search state is $globalSearchStateInfo.state", + ) + return globalSearchStateInfo.state } } diff --git a/photopicker/src/com/android/photopicker/data/UriHelper.kt b/photopicker/src/com/android/photopicker/data/UriHelper.kt index 9112d76f6..9dcf26e96 100644 --- a/photopicker/src/com/android/photopicker/data/UriHelper.kt +++ b/photopicker/src/com/android/photopicker/data/UriHelper.kt @@ -23,15 +23,18 @@ import android.provider.MediaStore /** Provides URI constants and helper functions. */ internal const val MEDIA_PROVIDER_AUTHORITY = MediaStore.AUTHORITY private const val UPDATE_PATH_SEGMENT = "update" -private const val AVAILABLE_PROVIDERS_PATH_SEGMENT = "available_providers" -private const val COLLECTION_INFO_SEGMENT = "collection_info" -private const val MEDIA_PATH_SEGMENT = "media" -private const val ALBUM_PATH_SEGMENT = "album" -private const val MEDIA_GRANTS_COUNT_PATH_SEGMENT = "media_grants_count" +const val AVAILABLE_PROVIDERS_PATH_SEGMENT = "available_providers" +const val COLLECTION_INFO_SEGMENT = "collection_info" +const val MEDIA_PATH_SEGMENT = "media" +const val ALBUM_PATH_SEGMENT = "album" +const val MEDIA_GRANTS_COUNT_PATH_SEGMENT = "media_grants_count" private const val PREVIEW_PATH_SEGMENT = "preview" -private const val PRE_SELECTION_URI_PATH_SEGMENT = "pre_selection" -private const val SEARCH_MEDIA_PATH_SEGMENT = "search_media" -private const val SEARCH_SUGGESTIONS_PATH_SEGMENT = "search_suggestions" +const val PRE_SELECTION_URI_PATH_SEGMENT = "pre_selection" +const val SEARCH_MEDIA_PATH_SEGMENT = "search_media" +const val SEARCH_SUGGESTIONS_PATH_SEGMENT = "search_suggestions" +const val CATEGORIES_PATH_SEGMENT = "categories" +const val MEDIA_SETS_PATH_SEGMENT = "media_sets" +const val MEDIA_SET_CONTENTS_PATH_SEGMENT = "media_set_contents" const val PICKER_SEGMENT = "picker" const val PICKER_TRANSCODED_SEGMENT = "picker_transcoded" @@ -109,6 +112,16 @@ fun getAlbumMediaUri(albumId: String): Uri { val SEARCH_SUGGESTIONS_URI: Uri = pickerUri.buildUpon().apply { appendPath(SEARCH_SUGGESTIONS_PATH_SEGMENT) }.build() +/** URI that receives [ContentProvider] change notifications for search result updates. */ +val SEARCH_RESULTS_UPDATE_URI: Uri = + pickerUri + .buildUpon() + .apply { + appendPath(SEARCH_MEDIA_PATH_SEGMENT) + appendPath(UPDATE_PATH_SEGMENT) + } + .build() + fun getSearchResultsMediaUri(searchRequestId: Int): Uri { return pickerUri .buildUpon() @@ -118,3 +131,18 @@ fun getSearchResultsMediaUri(searchRequestId: Int): Uri { } .build() } + +fun getCategoryUri(parentCategoryId: String?): Uri { + return pickerUri + .buildUpon() + .apply { + appendPath(CATEGORIES_PATH_SEGMENT) + parentCategoryId?.let { appendPath(parentCategoryId) } + } + .build() +} + +val MEDIA_SETS_URI = pickerUri.buildUpon().appendPath(MEDIA_SETS_PATH_SEGMENT).build() + +val MEDIA_SET_CONTENTS_URI = + pickerUri.buildUpon().appendPath(MEDIA_SET_CONTENTS_PATH_SEGMENT).build() diff --git a/photopicker/src/com/android/photopicker/features/categorygrid/data/CategoryDataService.kt b/photopicker/src/com/android/photopicker/features/categorygrid/data/CategoryDataService.kt index b40c4e921..e22843c4d 100644 --- a/photopicker/src/com/android/photopicker/features/categorygrid/data/CategoryDataService.kt +++ b/photopicker/src/com/android/photopicker/features/categorygrid/data/CategoryDataService.kt @@ -37,30 +37,17 @@ interface CategoryDataService { } /** - * Creates a paging source that can load root level categories and albums. Root level categories - * are the ones that have no parent (categories). + * Creates a paging source that can load categories and albums. * - * @param cancellationSignal An optional [CancellationSignal] that can be marked as cancelled - * when the query results are no longer required. - * @return The [PagingSource] that fetches a page using [GroupPageKey]. A page in the paging - * source contains a [List] of [Group.Category] or [Group.Album] items. - */ - fun getCategories( - cancellationSignal: CancellationSignal? = null - ): PagingSource<GroupPageKey, Group> - - /** - * Creates a paging source that can load child categories. Child categories are the ones that - * have a parent categories. - * - * @param category the parent [Category]. + * @param category the parent [Category]. If the parent category is null then the method returns + * root categories. * @param cancellationSignal An optional [CancellationSignal] that can be marked as cancelled * when the query results are no longer required. * @return The [PagingSource] that fetches a page using [GroupPageKey]. A page in the paging * source contains a [List] of [Group.Category] items. */ fun getCategories( - parentCategory: Group.Category, + parentCategory: Group.Category? = null, cancellationSignal: CancellationSignal? = null, ): PagingSource<GroupPageKey, Group> diff --git a/photopicker/src/com/android/photopicker/features/categorygrid/data/CategoryDataServiceImpl.kt b/photopicker/src/com/android/photopicker/features/categorygrid/data/CategoryDataServiceImpl.kt new file mode 100644 index 000000000..1acd39dd6 --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/categorygrid/data/CategoryDataServiceImpl.kt @@ -0,0 +1,334 @@ +/* + * 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.photopicker.features.categorygrid.data + +import android.content.ContentResolver +import android.os.CancellationSignal +import android.util.Log +import androidx.paging.PagingSource +import com.android.photopicker.core.configuration.PhotopickerConfiguration +import com.android.photopicker.core.events.Events +import com.android.photopicker.data.DataService +import com.android.photopicker.data.MediaProviderClient +import com.android.photopicker.data.NotificationService +import com.android.photopicker.data.model.Group +import com.android.photopicker.data.model.GroupPageKey +import com.android.photopicker.data.model.Media +import com.android.photopicker.data.model.MediaPageKey +import com.android.photopicker.data.model.Provider +import com.android.photopicker.features.categorygrid.paging.CategoryAndAlbumPagingSource +import com.android.photopicker.features.categorygrid.paging.MediaSetContentsPagingSource +import com.android.photopicker.features.categorygrid.paging.MediaSetsPagingSource +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Provides category feature data to the Photo Picker UI. The data comes from a [ContentProvider] + * called [MediaProvider]. + * + * Underlying data changes in [MediaProvider] are observed using [ContentObservers]. When a change + * in data is observed, the data is re-fetched from the [MediaProvider] process and the new data is + * emitted to the [StateFlows]-s. + * + * @param dataService Core Picker's data service that provides data related to core functionality. + * @param config A [StateFlow] that emits [PhotopickerConfiguration] changes. + * @param scope The [CoroutineScope] the data flows will be shared in. + * @param dispatcher A [CoroutineDispatcher] to run the coroutines in. + * @param notificationService An instance of [NotificationService] responsible to listen to data + * change notifications. + * @param mediaProviderClient An instance of [MediaProviderClient] responsible to get data from + * MediaProvider. + * @param events Event bus for the current session. + */ +class CategoryDataServiceImpl( + private val dataService: DataService, + private val config: StateFlow<PhotopickerConfiguration>, + private val scope: CoroutineScope, + private val dispatcher: CoroutineDispatcher, + private val notificationService: NotificationService, + private val mediaProviderClient: MediaProviderClient, + private val events: Events, +) : CategoryDataService { + private val cachedPagingSourceMutex = Mutex() + private var rootCategoryAndAlbumPagingSource: PagingSource<GroupPageKey, Group>? = null + private val childCategoryPagingSources: + MutableMap<Group.Category, PagingSource<GroupPageKey, Group>> = + mutableMapOf() + private val mediaSetPagingSources: + MutableMap<Group.Category, PagingSource<GroupPageKey, Group.MediaSet>> = + mutableMapOf() + private val mediaSetContentPagingSources: + MutableMap<Group.MediaSet, PagingSource<MediaPageKey, Media>> = + mutableMapOf() + + init { + // Listen to available provider changes and clear category cache when required. + scope.launch(dispatcher) { + dataService.availableProviders.collect { providers: List<Provider> -> + Log.d( + CategoryDataService.TAG, + "Available providers have changed to $providers. " + + "Clearing category results cache.", + ) + + cachedPagingSourceMutex.withLock { + rootCategoryAndAlbumPagingSource?.invalidate() + childCategoryPagingSources.values.forEach { pagingSource -> + pagingSource.invalidate() + } + childCategoryPagingSources.clear() + + mediaSetPagingSources.values.forEach { pagingSource -> + pagingSource.invalidate() + } + mediaSetPagingSources.clear() + + mediaSetContentPagingSources.values.forEach { pagingSource -> + pagingSource.invalidate() + } + mediaSetContentPagingSources.clear() + } + } + } + } + + override fun getCategories( + parentCategory: Group.Category?, + cancellationSignal: CancellationSignal?, + ): PagingSource<GroupPageKey, Group> = runBlocking { + return@runBlocking cachedPagingSourceMutex.withLock { + return@withLock when { + parentCategory == null && + rootCategoryAndAlbumPagingSource != null && + !rootCategoryAndAlbumPagingSource!!.invalid -> { + Log.d( + CategoryDataService.TAG, + "A valid paging source is available for root categories and albums. " + + "Not creating a new paging source.", + ) + + val pagingSource = rootCategoryAndAlbumPagingSource!! + // Register the new cancellation signal to be cancelled in the callback. + pagingSource.registerInvalidatedCallback { cancellationSignal?.cancel() } + pagingSource + } + + parentCategory != null && + childCategoryPagingSources.containsKey(parentCategory) && + !childCategoryPagingSources[parentCategory]!!.invalid -> { + Log.d( + CategoryDataService.TAG, + "A valid paging source is available for category ${parentCategory.categoryType}. " + + "Not creating a new paging source.", + ) + + val pagingSource = childCategoryPagingSources[parentCategory]!! + // Register the new cancellation signal to be cancelled in the callback. + pagingSource.registerInvalidatedCallback { cancellationSignal?.cancel() } + pagingSource + } + + else -> { + val availableProviders: List<Provider> = dataService.availableProviders.value + val contentResolver: ContentResolver = dataService.activeContentResolver.value + val pagingSource = + CategoryAndAlbumPagingSource( + contentResolver = contentResolver, + availableProviders = availableProviders, + parentCategoryId = parentCategory?.id, + mediaProviderClient = mediaProviderClient, + dispatcher = dispatcher, + configuration = config.value, + events = events, + cancellationSignal = cancellationSignal, + ) + // Ensure that cancellation get propagated to the data source when the paging + // source + // is invalidated. + pagingSource.registerInvalidatedCallback { cancellationSignal?.cancel() } + + Log.v( + CategoryDataService.TAG, + "Created a category paging source that queries $availableProviders for " + + "parent category id $parentCategory", + ) + + // Update paging source cache. + when { + parentCategory == null -> rootCategoryAndAlbumPagingSource = pagingSource + else -> childCategoryPagingSources[parentCategory] = pagingSource + } + + pagingSource + } + } + } + } + + override fun getMediaSets( + category: Group.Category, + cancellationSignal: CancellationSignal?, + ): PagingSource<GroupPageKey, Group.MediaSet> = runBlocking { + return@runBlocking cachedPagingSourceMutex.withLock { + if ( + mediaSetPagingSources.containsKey(category) && + !mediaSetPagingSources[category]!!.invalid + ) { + Log.d( + CategoryDataService.TAG, + "A valid paging source is available for media sets ${category.categoryType}. " + + "Not creating a new paging source.", + ) + val pagingSource = mediaSetPagingSources[category]!! + // Register the new cancellation signal to be cancelled in the callback. + pagingSource.registerInvalidatedCallback { cancellationSignal?.cancel() } + pagingSource + } else { + refreshMediaSets(category) + + val availableProviders: List<Provider> = dataService.availableProviders.value + val contentResolver: ContentResolver = dataService.activeContentResolver.value + val pagingSource = + MediaSetsPagingSource( + contentResolver = contentResolver, + availableProviders = availableProviders, + parentCategory = category, + mediaProviderClient = mediaProviderClient, + dispatcher = dispatcher, + configuration = config.value, + events = events, + cancellationSignal = cancellationSignal, + ) + // Ensure that cancellation get propagated to the data source when the paging source + // is invalidated. + pagingSource.registerInvalidatedCallback { cancellationSignal?.cancel() } + + Log.v( + CategoryDataService.TAG, + "Created a media source paging source that queries $availableProviders for " + + "parent category id $category", + ) + + mediaSetPagingSources[category] = pagingSource + pagingSource + } + } + } + + override fun getMediaSetContents( + mediaSet: Group.MediaSet, + cancellationSignal: CancellationSignal?, + ): PagingSource<MediaPageKey, Media> = runBlocking { + return@runBlocking cachedPagingSourceMutex.withLock { + if ( + mediaSetContentPagingSources.containsKey(mediaSet) && + !mediaSetContentPagingSources[mediaSet]!!.invalid + ) { + Log.d( + CategoryDataService.TAG, + "A valid paging source is available for media set content ${mediaSet.id}. " + + "Not creating a new paging source.", + ) + val pagingSource = mediaSetContentPagingSources[mediaSet]!! + // Register the new cancellation signal to be cancelled in the callback. + pagingSource.registerInvalidatedCallback { cancellationSignal?.cancel() } + pagingSource + } else { + refreshMediaSetContents(mediaSet) + + val availableProviders: List<Provider> = dataService.availableProviders.value + val contentResolver: ContentResolver = dataService.activeContentResolver.value + val pagingSource = + MediaSetContentsPagingSource( + contentResolver = contentResolver, + availableProviders = availableProviders, + parentMediaSet = mediaSet, + mediaProviderClient = mediaProviderClient, + dispatcher = dispatcher, + configuration = config.value, + events = events, + cancellationSignal = cancellationSignal, + ) + // Ensure that cancellation get propagated to the data source when the paging source + // is invalidated. + pagingSource.registerInvalidatedCallback { cancellationSignal?.cancel() } + + Log.v( + CategoryDataService.TAG, + "Created a media source paging source that queries $availableProviders for " + + "parent media set id ${mediaSet.id}", + ) + + mediaSetContentPagingSources[mediaSet] = pagingSource + pagingSource + } + } + } + + private suspend fun refreshMediaSets(category: Group.Category) { + val providers = dataService.availableProviders.value + val contentResolver = dataService.activeContentResolver.value + val isCategoryProviderAvailable = + providers.any { provider -> provider.authority == category.authority } + + if (isCategoryProviderAvailable) { + Log.d( + CategoryDataService.TAG, + "Sending media sets refresh request to the data source" + + " for parent category ${category.categoryType}", + ) + + mediaProviderClient.refreshMediaSets(contentResolver, category, config.value) + } else { + Log.e( + CategoryDataService.TAG, + "Available providers $providers " + + "does not contain category authority ${category.authority}. " + + "Skip sending refresh media sets request.", + ) + } + } + + private suspend fun refreshMediaSetContents(mediaSet: Group.MediaSet) { + val providers = dataService.availableProviders.value + val contentResolver = dataService.activeContentResolver.value + val isMediaSetProviderAvailable = + providers.any { provider -> provider.authority == mediaSet.authority } + + if (isMediaSetProviderAvailable) { + Log.d( + CategoryDataService.TAG, + "Sending media set contents refresh request to the data source" + + " for parent media set ${mediaSet.id}", + ) + + mediaProviderClient.refreshMediaSetContents(contentResolver, mediaSet, config.value) + } else { + Log.e( + CategoryDataService.TAG, + "Available providers $providers " + + "does not contain media set authority ${mediaSet.authority}. " + + "Skip sending refresh media set contents request.", + ) + } + } +} diff --git a/photopicker/src/com/android/photopicker/features/categorygrid/data/FakeCategoryDataServiceImpl.kt b/photopicker/src/com/android/photopicker/features/categorygrid/data/FakeCategoryDataServiceImpl.kt deleted file mode 100644 index 858239176..000000000 --- a/photopicker/src/com/android/photopicker/features/categorygrid/data/FakeCategoryDataServiceImpl.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.photopicker.features.categorygrid.data - -import android.os.CancellationSignal -import androidx.paging.PagingSource -import com.android.photopicker.data.DataService -import com.android.photopicker.data.model.Group -import com.android.photopicker.data.model.GroupPageKey -import com.android.photopicker.data.model.Media -import com.android.photopicker.data.model.MediaPageKey - -/** - * Placeholder for the actual [CategoryDataService] implementation class. This class can be used to - * unblock and test UI development till we have the actual implementation in ready. - */ -class FakeCategoryDataServiceImpl(val coreDataService: DataService) : CategoryDataService { - override fun getCategories( - cancellationSignal: CancellationSignal? - ): PagingSource<GroupPageKey, Group> { - TODO("Not yet implemented") - } - - override fun getCategories( - parentCategory: Group.Category, - cancellationSignal: CancellationSignal?, - ): PagingSource<GroupPageKey, Group> { - TODO("Not yet implemented") - } - - override fun getMediaSets( - category: Group.Category, - cancellationSignal: CancellationSignal?, - ): PagingSource<GroupPageKey, Group.MediaSet> { - TODO("Not yet implemented") - } - - // Returns paging source for the main media grid. - override fun getMediaSetContents( - mediaSet: Group.MediaSet, - cancellationSignal: CancellationSignal?, - ): PagingSource<MediaPageKey, Media> { - return coreDataService.mediaPagingSource() - } -} diff --git a/photopicker/src/com/android/photopicker/features/categorygrid/inject/CategoryActivityRetainedModule.kt b/photopicker/src/com/android/photopicker/features/categorygrid/inject/CategoryActivityRetainedModule.kt index 2c9e26bf5..59e34f073 100644 --- a/photopicker/src/com/android/photopicker/features/categorygrid/inject/CategoryActivityRetainedModule.kt +++ b/photopicker/src/com/android/photopicker/features/categorygrid/inject/CategoryActivityRetainedModule.kt @@ -17,14 +17,21 @@ package com.android.photopicker.features.categorygrid.inject import android.util.Log +import com.android.photopicker.core.Background +import com.android.photopicker.core.configuration.ConfigurationManager +import com.android.photopicker.core.events.Events import com.android.photopicker.data.DataService +import com.android.photopicker.data.MediaProviderClient +import com.android.photopicker.data.NotificationService import com.android.photopicker.features.categorygrid.data.CategoryDataService -import com.android.photopicker.features.categorygrid.data.FakeCategoryDataServiceImpl +import com.android.photopicker.features.categorygrid.data.CategoryDataServiceImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityRetainedComponent import dagger.hilt.android.scopes.ActivityRetainedScoped +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope /** * Injection Module for category feature specific dependencies, that provides access to objects @@ -51,7 +58,15 @@ class CategoryActivityRetainedModule { /** Provider for an implementation of [CategoryDataService]. */ @Provides @ActivityRetainedScoped - fun provideCategoryDataService(dataService: DataService): CategoryDataService { + fun provideCategoryDataService( + dataService: DataService, + configurationManager: ConfigurationManager, + @Background scope: CoroutineScope, + @Background dispatcher: CoroutineDispatcher, + mediaProviderClient: MediaProviderClient, + notificationService: NotificationService, + events: Events, + ): CategoryDataService { if (::categoryDataService.isInitialized) { return categoryDataService } else { @@ -61,8 +76,16 @@ class CategoryActivityRetainedModule { " Initializing CategoryDataService.", ) - // TODO(b/361043596): Switch with actual implementation when ready. - categoryDataService = FakeCategoryDataServiceImpl(dataService) + categoryDataService = + CategoryDataServiceImpl( + dataService, + configurationManager.configuration, + scope, + dispatcher, + notificationService, + mediaProviderClient, + events, + ) return categoryDataService } } diff --git a/photopicker/src/com/android/photopicker/features/categorygrid/inject/CategoryEmbeddedServiceModule.kt b/photopicker/src/com/android/photopicker/features/categorygrid/inject/CategoryEmbeddedServiceModule.kt index 13b552f8a..503badaec 100644 --- a/photopicker/src/com/android/photopicker/features/categorygrid/inject/CategoryEmbeddedServiceModule.kt +++ b/photopicker/src/com/android/photopicker/features/categorygrid/inject/CategoryEmbeddedServiceModule.kt @@ -17,14 +17,21 @@ package com.android.photopicker.features.categorygrid.inject import android.util.Log +import com.android.photopicker.core.Background import com.android.photopicker.core.EmbeddedServiceComponent import com.android.photopicker.core.SessionScoped +import com.android.photopicker.core.configuration.ConfigurationManager +import com.android.photopicker.core.events.Events import com.android.photopicker.data.DataService +import com.android.photopicker.data.MediaProviderClient +import com.android.photopicker.data.NotificationService import com.android.photopicker.features.categorygrid.data.CategoryDataService -import com.android.photopicker.features.categorygrid.data.FakeCategoryDataServiceImpl +import com.android.photopicker.features.categorygrid.data.CategoryDataServiceImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope /** * Injection Module for category feature specific dependencies, that provides access to objects @@ -49,7 +56,15 @@ class CategoryEmbeddedServiceModule { /** Provider for an implementation of [CategoryDataService]. */ @Provides @SessionScoped - fun provideCategoryDataService(dataService: DataService): CategoryDataService { + fun provideCategoryDataService( + dataService: DataService, + configurationManager: ConfigurationManager, + @Background scope: CoroutineScope, + @Background dispatcher: CoroutineDispatcher, + mediaProviderClient: MediaProviderClient, + notificationService: NotificationService, + events: Events, + ): CategoryDataService { if (::categoryDataService.isInitialized) { return categoryDataService } else { @@ -59,8 +74,16 @@ class CategoryEmbeddedServiceModule { " Initializing CategoryDataService.", ) - // TODO(b/361043596): Switch with actual implementation when ready. - categoryDataService = FakeCategoryDataServiceImpl(dataService) + categoryDataService = + CategoryDataServiceImpl( + dataService, + configurationManager.configuration, + scope, + dispatcher, + notificationService, + mediaProviderClient, + events, + ) return categoryDataService } } diff --git a/photopicker/src/com/android/photopicker/features/categorygrid/paging/CategoryAndAlbumPagingSource.kt b/photopicker/src/com/android/photopicker/features/categorygrid/paging/CategoryAndAlbumPagingSource.kt new file mode 100644 index 000000000..6b196604a --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/categorygrid/paging/CategoryAndAlbumPagingSource.kt @@ -0,0 +1,82 @@ +/* + * 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.photopicker.features.categorygrid.paging + +import android.content.ContentResolver +import android.os.CancellationSignal +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.android.photopicker.core.configuration.PhotopickerConfiguration +import com.android.photopicker.core.events.Events +import com.android.photopicker.data.MediaProviderClient +import com.android.photopicker.data.model.Group +import com.android.photopicker.data.model.GroupPageKey +import com.android.photopicker.data.model.Provider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** + * This [PagingSource] class is responsible to providing paginated category and album data from + * Picker Backend by serving requests from Paging library. It sources data from a [ContentProvider] + * called [MediaProvider]. + */ +class CategoryAndAlbumPagingSource( + private val contentResolver: ContentResolver, + private val availableProviders: List<Provider>, + private val parentCategoryId: String?, + private val mediaProviderClient: MediaProviderClient, + private val dispatcher: CoroutineDispatcher, + private val configuration: PhotopickerConfiguration, + private val events: Events, + private val cancellationSignal: CancellationSignal?, +) : PagingSource<GroupPageKey, Group>() { + companion object { + val TAG: String = "PickerCategoryPagingSource" + } + + override suspend fun load(params: LoadParams<GroupPageKey>): LoadResult<GroupPageKey, Group> { + val pageKey = params.key ?: GroupPageKey() + val pageSize = params.loadSize + // Switch to the background thread from the main thread using [withContext]. + val result = + withContext(dispatcher) { + try { + if (availableProviders.isEmpty()) { + throw IllegalArgumentException("No available providers found.") + } + + mediaProviderClient.fetchCategoriesAndAlbums( + pageKey, + pageSize, + contentResolver, + availableProviders, + parentCategoryId, + configuration, + cancellationSignal, + ) + } catch (e: Exception) { + Log.e(TAG, "Could not fetch page from Media provider", e) + LoadResult.Error(e) + } + } + + return result + } + + override fun getRefreshKey(state: PagingState<GroupPageKey, Group>): GroupPageKey? = null +} diff --git a/photopicker/src/com/android/photopicker/features/categorygrid/paging/MediaSetContentsPagingSource.kt b/photopicker/src/com/android/photopicker/features/categorygrid/paging/MediaSetContentsPagingSource.kt new file mode 100644 index 000000000..b7d7b5b3e --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/categorygrid/paging/MediaSetContentsPagingSource.kt @@ -0,0 +1,83 @@ +/* + * 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.photopicker.features.categorygrid.paging + +import android.content.ContentResolver +import android.os.CancellationSignal +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.android.photopicker.core.configuration.PhotopickerConfiguration +import com.android.photopicker.core.events.Events +import com.android.photopicker.data.MediaProviderClient +import com.android.photopicker.data.model.Group +import com.android.photopicker.data.model.Media +import com.android.photopicker.data.model.MediaPageKey +import com.android.photopicker.data.model.Provider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** + * This [PagingSource] class is responsible to providing paginated media items in media set Picker + * Backend by serving requests from Paging library. It sources data from a [ContentProvider] called + * [MediaProvider]. + */ +class MediaSetContentsPagingSource( + val contentResolver: ContentResolver, + private val availableProviders: List<Provider>, + private val parentMediaSet: Group.MediaSet, + private val mediaProviderClient: MediaProviderClient, + private val dispatcher: CoroutineDispatcher, + private val configuration: PhotopickerConfiguration, + private val events: Events, + private val cancellationSignal: CancellationSignal?, +) : PagingSource<MediaPageKey, Media>() { + companion object { + val TAG: String = "PickerMediaSetContentPagingSource" + } + + override suspend fun load(params: LoadParams<MediaPageKey>): LoadResult<MediaPageKey, Media> { + val pageKey = params.key ?: MediaPageKey() + val pageSize = params.loadSize + // Switch to the background thread from the main thread using [withContext]. + val result = + withContext(dispatcher) { + try { + if (availableProviders.isEmpty()) { + throw IllegalArgumentException("No available providers found.") + } + + mediaProviderClient.fetchMediaSetContents( + pageKey, + pageSize, + contentResolver, + availableProviders, + parentMediaSet, + configuration, + cancellationSignal, + ) + } catch (e: Exception) { + Log.e(TAG, "Could not fetch page from Media provider", e) + LoadResult.Error(e) + } + } + + return result + } + + override fun getRefreshKey(state: PagingState<MediaPageKey, Media>): MediaPageKey? = null +} diff --git a/photopicker/src/com/android/photopicker/features/categorygrid/paging/MediaSetsPagingSource.kt b/photopicker/src/com/android/photopicker/features/categorygrid/paging/MediaSetsPagingSource.kt new file mode 100644 index 000000000..5b3752705 --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/categorygrid/paging/MediaSetsPagingSource.kt @@ -0,0 +1,85 @@ +/* + * 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.photopicker.features.categorygrid.paging + +import android.content.ContentResolver +import android.os.CancellationSignal +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.android.photopicker.core.configuration.PhotopickerConfiguration +import com.android.photopicker.core.events.Events +import com.android.photopicker.data.MediaProviderClient +import com.android.photopicker.data.model.Group +import com.android.photopicker.data.model.GroupPageKey +import com.android.photopicker.data.model.Provider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** + * This [PagingSource] class is responsible to providing paginated media sets data from Picker + * Backend by serving requests from Paging library. It sources data from a [ContentProvider] called + * [MediaProvider]. + */ +class MediaSetsPagingSource( + val contentResolver: ContentResolver, + private val availableProviders: List<Provider>, + private val parentCategory: Group.Category, + private val mediaProviderClient: MediaProviderClient, + private val dispatcher: CoroutineDispatcher, + private val configuration: PhotopickerConfiguration, + private val events: Events, + private val cancellationSignal: CancellationSignal?, +) : PagingSource<GroupPageKey, Group.MediaSet>() { + companion object { + val TAG: String = "PickerMediaSetPagingSource" + } + + override suspend fun load( + params: LoadParams<GroupPageKey> + ): LoadResult<GroupPageKey, Group.MediaSet> { + val pageKey = params.key ?: GroupPageKey() + val pageSize = params.loadSize + // Switch to the background thread from the main thread using [withContext]. + val result = + withContext(dispatcher) { + try { + if (availableProviders.isEmpty()) { + throw IllegalArgumentException("No available providers found.") + } + + mediaProviderClient.fetchMediaSets( + pageKey, + pageSize, + contentResolver, + availableProviders, + parentCategory, + configuration, + cancellationSignal, + ) + } catch (e: Exception) { + Log.e(TAG, "Could not fetch page from Media provider", e) + LoadResult.Error(e) + } + } + + return result + } + + override fun getRefreshKey(state: PagingState<GroupPageKey, Group.MediaSet>): GroupPageKey? = + null +} diff --git a/photopicker/src/com/android/photopicker/features/search/Search.kt b/photopicker/src/com/android/photopicker/features/search/Search.kt index 400d13c83..ea6b95e64 100644 --- a/photopicker/src/com/android/photopicker/features/search/Search.kt +++ b/photopicker/src/com/android/photopicker/features/search/Search.kt @@ -104,9 +104,9 @@ import com.android.photopicker.core.selection.LocalSelection import com.android.photopicker.core.theme.LocalWindowSizeClass import com.android.photopicker.extensions.navigateToPreviewMedia import com.android.photopicker.features.preview.PreviewFeature -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchSuggestion import com.android.photopicker.features.search.model.SearchSuggestionType +import com.android.photopicker.features.search.model.UserSearchState import com.android.photopicker.util.rememberBitmapFromUri import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -118,7 +118,7 @@ private val MEASUREMENT_SEARCH_BAR_HEIGHT = 56.dp private val MEASUREMENT_SEARCH_BAR_PADDING = PaddingValues(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 16.dp) -private val FETCH_SUGGESTION_DEBOUNCE_DELAY = 300L // in milliseconds +private val FETCH_SUGGESTION_DEBOUNCE_DELAY = 50L // in milliseconds private val SUGGESTION_TITLE_PADDING = PaddingValues(start = 32.dp, end = 32.dp, top = 12.dp, bottom = 12.dp) @@ -161,9 +161,9 @@ fun Search( params: LocationParams, viewModel: SearchViewModel = obtainViewModel(), ) { - val searchEnabled by viewModel.searchEnabled.collectAsStateWithLifecycle() + val userSearchStateInfo by viewModel.userSearchStateInfo.collectAsStateWithLifecycle() when { - searchEnabled == SearchEnabledState.ENABLED -> { + userSearchStateInfo.state == UserSearchState.ENABLED -> { SearchBarEnabled(params, viewModel, modifier) } else -> { @@ -633,11 +633,13 @@ private fun ShowSuggestions( onSuggestionClick, ) } - item { - Text( - text = stringResource(R.string.photopicker_search_suggestions_text), - modifier = Modifier.padding(SUGGESTION_TITLE_PADDING), - ) + if (faceSuggestions.isNotEmpty() || otherSuggestions.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.photopicker_search_suggestions_text), + modifier = Modifier.padding(SUGGESTION_TITLE_PADDING), + ) + } } if (faceSuggestions.size > 0) { item { diff --git a/photopicker/src/com/android/photopicker/features/search/SearchFeature.kt b/photopicker/src/com/android/photopicker/features/search/SearchFeature.kt index 26ed22801..343f5776e 100644 --- a/photopicker/src/com/android/photopicker/features/search/SearchFeature.kt +++ b/photopicker/src/com/android/photopicker/features/search/SearchFeature.kt @@ -31,7 +31,7 @@ import com.android.photopicker.core.features.PhotopickerUiFeature import com.android.photopicker.core.features.PrefetchResultKey import com.android.photopicker.core.features.Priority import com.android.photopicker.data.PrefetchDataService -import com.android.photopicker.features.search.model.SearchEnabledState +import com.android.photopicker.features.search.model.GlobalSearchState import kotlinx.coroutines.Deferred import kotlinx.coroutines.runBlocking @@ -51,7 +51,7 @@ class SearchFeature : PhotopickerUiFeature { mapOf( PrefetchResultKey.SEARCH_STATE to { prefetchDataService -> - prefetchDataService.getSearchState() + prefetchDataService.getGlobalSearchState() } ) } else { @@ -72,7 +72,9 @@ class SearchFeature : PhotopickerUiFeature { val searchStatus: Any? = deferredPrefetchResultsMap[PrefetchResultKey.SEARCH_STATE]?.await() when (searchStatus) { - is SearchEnabledState -> searchStatus == SearchEnabledState.ENABLED + is GlobalSearchState -> + searchStatus == GlobalSearchState.ENABLED || + searchStatus == GlobalSearchState.ENABLED_IN_OTHER_PROFILES_ONLY else -> false // prefetch may have timed out } } diff --git a/photopicker/src/com/android/photopicker/features/search/SearchViewModel.kt b/photopicker/src/com/android/photopicker/features/search/SearchViewModel.kt index e55bd7cd0..47f001991 100644 --- a/photopicker/src/com/android/photopicker/features/search/SearchViewModel.kt +++ b/photopicker/src/com/android/photopicker/features/search/SearchViewModel.kt @@ -35,9 +35,9 @@ import com.android.photopicker.data.model.Media import com.android.photopicker.extensions.insertMonthSeparators import com.android.photopicker.extensions.toMediaGridItemFromMedia import com.android.photopicker.features.search.data.SearchDataService -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchSuggestion import com.android.photopicker.features.search.model.SearchSuggestionType +import com.android.photopicker.features.search.model.UserSearchStateInfo import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher @@ -109,7 +109,7 @@ constructor( * * This `StateFlow` emits updates whenever the search enabled state of a profile changes. */ - val searchEnabled: StateFlow<SearchEnabledState> = searchDataService.isSearchEnabled + val userSearchStateInfo: StateFlow<UserSearchStateInfo> = searchDataService.userSearchStateInfo private val suggestionCache = SearchSuggestionCache() diff --git a/photopicker/src/com/android/photopicker/features/search/data/FakeSearchDataServiceImpl.kt b/photopicker/src/com/android/photopicker/features/search/data/FakeSearchDataServiceImpl.kt deleted file mode 100644 index 3e6adbeea..000000000 --- a/photopicker/src/com/android/photopicker/features/search/data/FakeSearchDataServiceImpl.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 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.photopicker.features.search.data - -import android.net.Uri -import android.os.CancellationSignal -import androidx.paging.PagingSource -import com.android.photopicker.data.DataService -import com.android.photopicker.data.model.Media -import com.android.photopicker.data.model.MediaPageKey -import com.android.photopicker.features.search.model.SearchEnabledState -import com.android.photopicker.features.search.model.SearchSuggestion -import com.android.photopicker.features.search.model.SearchSuggestionType -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -/** - * Placeholder for the actual [SearchDataService] implementation class. This class can be used to - * unblock and test UI development till we have the actual implementation in ready. - */ -// TODO(b/361043596) Clean up once we have the implementation for [SearchDataService] class. -class FakeSearchDataServiceImpl(private val dataService: DataService) : SearchDataService { - // Use the internal flow of type StateFlow<Map<UserProfile, Boolean>> which would cache - // the result for all profiles, to populate this flow for the current profile. - override val isSearchEnabled: StateFlow<SearchEnabledState> = - MutableStateFlow(SearchEnabledState.ENABLED) - - /** Returns a few static suggestions to unblock UI development. */ - override suspend fun getSearchSuggestions( - prefix: String, - limit: Int, - cancellationSignal: CancellationSignal?, - ): List<SearchSuggestion> { - if (prefix == "testempty") { - return emptyList() - } - return listOf( - SearchSuggestion("1", "authority", "France", SearchSuggestionType.LOCATION, null), - SearchSuggestion( - "2", - "authority", - "Favorites", - SearchSuggestionType.FAVORITES_ALBUM, - Uri.parse("xyz"), - ), - SearchSuggestion( - "8", - "authority", - "Album", - SearchSuggestionType.ALBUM, - Uri.parse("xyz"), - ), - SearchSuggestion("2", "authority", "Videos", SearchSuggestionType.VIDEOS_ALBUM, null), - SearchSuggestion(null, "authority", "france", SearchSuggestionType.HISTORY, null), - SearchSuggestion(null, "authority", "paris", SearchSuggestionType.HISTORY, null), - SearchSuggestion("3", "authority", "March", SearchSuggestionType.DATE, null), - SearchSuggestion( - "3", - "authority", - "Screenshot", - SearchSuggestionType.SCREENSHOTS_ALBUM, - null, - ), - SearchSuggestion("4", "authority", "Emma", SearchSuggestionType.FACE, Uri.parse("xyz")), - SearchSuggestion("5", "authority", "Bob", SearchSuggestionType.FACE, Uri.parse("xyz")), - SearchSuggestion("6", "authority", "April", SearchSuggestionType.DATE, null), - SearchSuggestion("7", "authority", null, SearchSuggestionType.FACE, Uri.parse("xyz")), - ) - } - - /** Returns all media to unblock UI development. */ - override fun getSearchResults( - suggestion: SearchSuggestion, - cancellationSignal: CancellationSignal?, - ): PagingSource<MediaPageKey, Media> = dataService.mediaPagingSource() - - /** Returns all media to unblock UI development. */ - override fun getSearchResults( - searchText: String, - cancellationSignal: CancellationSignal?, - ): PagingSource<MediaPageKey, Media> = dataService.mediaPagingSource() -} diff --git a/photopicker/src/com/android/photopicker/features/search/data/SearchDataService.kt b/photopicker/src/com/android/photopicker/features/search/data/SearchDataService.kt index d04aac74b..9bb5691e5 100644 --- a/photopicker/src/com/android/photopicker/features/search/data/SearchDataService.kt +++ b/photopicker/src/com/android/photopicker/features/search/data/SearchDataService.kt @@ -20,8 +20,8 @@ import android.os.CancellationSignal import androidx.paging.PagingSource import com.android.photopicker.data.model.Media import com.android.photopicker.data.model.MediaPageKey -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchSuggestion +import com.android.photopicker.features.search.model.UserSearchStateInfo import kotlinx.coroutines.flow.StateFlow /** @@ -37,11 +37,11 @@ interface SearchDataService { } /** - * A [StateFlow] that emits a value when current profile changes or search config in the data - * source changes. It hold that value of the current profile's search enabled state - * [SearchEnabledState]. + * A [StateFlow] that emits a value when current profile changes or the current profile's + * available provider changes. It hold that value of the current profile's search enabled state + * [UserSearchStateInfo]. */ - val isSearchEnabled: StateFlow<SearchEnabledState> + val userSearchStateInfo: StateFlow<UserSearchStateInfo> /** * Get search suggestions for the user in zero state and as the user is typing. diff --git a/photopicker/src/com/android/photopicker/features/search/data/SearchDataServiceImpl.kt b/photopicker/src/com/android/photopicker/features/search/data/SearchDataServiceImpl.kt index f2ad67545..9aacde80f 100644 --- a/photopicker/src/com/android/photopicker/features/search/data/SearchDataServiceImpl.kt +++ b/photopicker/src/com/android/photopicker/features/search/data/SearchDataServiceImpl.kt @@ -17,6 +17,8 @@ package com.android.photopicker.features.search.data import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri import android.os.CancellationSignal import android.util.Log import androidx.paging.PagingSource @@ -26,17 +28,23 @@ import com.android.photopicker.core.user.UserStatus import com.android.photopicker.data.DataService import com.android.photopicker.data.MediaProviderClient import com.android.photopicker.data.NotificationService +import com.android.photopicker.data.SEARCH_RESULTS_UPDATE_URI import com.android.photopicker.data.model.Media import com.android.photopicker.data.model.MediaPageKey import com.android.photopicker.data.model.Provider -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchRequest import com.android.photopicker.features.search.model.SearchSuggestion +import com.android.photopicker.features.search.model.UserSearchStateInfo import java.util.concurrent.TimeoutException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -75,7 +83,7 @@ class SearchDataServiceImpl( ) : SearchDataService { companion object { // Timeout for receiving suggestions from the data source in milli seconds. - private const val SUGGESTIONS_TIMEOUT: Long = 500 + private const val SUGGESTIONS_TIMEOUT: Long = 1500 } // An internal lock to allow thread-safe updates to the search request and results cache. @@ -88,6 +96,14 @@ class SearchDataServiceImpl( private val searchResultsPagingSources: MutableMap<Int, PagingSource<MediaPageKey, Media>> = mutableMapOf() + // Callback flow that listens to changes in search results and emits the search request id when + // change is observed. + private var searchResultsUpdateCallbackFlow: Flow<Int>? = null + + // Saves the current job that collects the [searchResultsUpdateCallbackFlow]. + // Cancel this job when there is a change in the current profile's content resolver. + private var searchResultsUpdateCollectJob: Job? = null + init { // Listen to available provider changes and clear search cache when required. scope.launch(dispatcher) { @@ -106,13 +122,42 @@ class SearchDataServiceImpl( searchResultsPagingSources.clear() searchRequestIdMap.clear() } + + _userSearchStateInfo.update { fetchSearchStateInfo() } + } + } + + scope.launch(dispatcher) { + // Only observe the changes in the active content resolver + dataService.activeContentResolver.collect { activeContentResolver: ContentResolver -> + Log.d(SearchDataService.TAG, "Active content resolver has changed.") + + // Stop collecting search results updates from previously initialized callback flow. + searchResultsUpdateCollectJob?.cancel() + searchResultsUpdateCallbackFlow = initSearchResultsUpdateFlow(activeContentResolver) + + searchResultsUpdateCollectJob = + scope.launch(dispatcher) { + searchResultsUpdateCallbackFlow?.collect { searchRequestId: Int -> + Log.d( + SearchDataService.TAG, + "Search results update notification " + + "received for search request id $searchRequestId ", + ) + searchResultsPagingSourceMutex.withLock { + searchResultsPagingSources[searchRequestId]?.invalidate() + } + } + } } } } - // TODO(b/381819838) - override val isSearchEnabled: StateFlow<SearchEnabledState> = - MutableStateFlow(SearchEnabledState.ENABLED) + // Internal mutable flow of the current user's search state info. + private val _userSearchStateInfo: MutableStateFlow<UserSearchStateInfo> = + MutableStateFlow(UserSearchStateInfo(null)) + + override val userSearchStateInfo: StateFlow<UserSearchStateInfo> = _userSearchStateInfo /** * Try to get a list fo search suggestions from Media Provider in the background thread with a @@ -202,7 +247,7 @@ class SearchDataServiceImpl( searchResultsPagingSourceMutex.withLock { if ( searchResultsPagingSources.containsKey(searchRequestId) && - searchResultsPagingSources[searchRequestId]!!.invalid + !searchResultsPagingSources[searchRequestId]!!.invalid ) { Log.d( SearchDataService.TAG, @@ -232,7 +277,8 @@ class SearchDataServiceImpl( Log.d( DataService.TAG, - "Created a search results paging source that queries $availableProviders", + "Created a search results paging source that queries $availableProviders " + + "for search request id $searchRequestId", ) searchResultsPagingSources[searchRequestId] = searchResultsPagingSource @@ -287,4 +333,52 @@ class SearchDataServiceImpl( } } } + + /** Get search state info for the current user. */ + private suspend fun fetchSearchStateInfo(): UserSearchStateInfo { + val contentResolver: ContentResolver = dataService.activeContentResolver.value + val searchProviderAuthorities: List<String>? = + mediaProviderClient.fetchSearchProviderAuthorities( + contentResolver, + dataService.availableProviders.value, + ) + val userSearchStateInfo = UserSearchStateInfo(searchProviderAuthorities) + Log.d( + SearchDataService.TAG, + "Available search providers for current user $searchProviderAuthorities. " + + "Search state is ${userSearchStateInfo.state}", + ) + return userSearchStateInfo + } + + /** + * Creates a callback flow that emits search request id when an update in search results is + * observed using [ContentObserver] notifications. + */ + private fun initSearchResultsUpdateFlow(resolver: ContentResolver): Flow<Int> = callbackFlow { + val observer = + object : ContentObserver(/* handler */ null) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + // Verify that search request id is present in the URI + if ( + uri?.pathSegments?.size == (1 + SEARCH_RESULTS_UPDATE_URI.pathSegments.size) + ) { + val searchRequestId: Int = + Integer.parseInt(uri.pathSegments[uri.pathSegments.size - 1] ?: "-1") + trySend(searchRequestId) + } + } + } + + // Register the content observer callback. + notificationService.registerContentObserverCallback( + resolver, + SEARCH_RESULTS_UPDATE_URI, + /* notifyForDescendants */ true, + observer, + ) + + // Unregister when the flow is closed. + awaitClose { notificationService.unregisterContentObserverCallback(resolver, observer) } + } } diff --git a/photopicker/src/com/android/photopicker/features/search/data/SearchResultsPagingSource.kt b/photopicker/src/com/android/photopicker/features/search/data/SearchResultsPagingSource.kt index 12938b520..88fce10fb 100644 --- a/photopicker/src/com/android/photopicker/features/search/data/SearchResultsPagingSource.kt +++ b/photopicker/src/com/android/photopicker/features/search/data/SearchResultsPagingSource.kt @@ -61,15 +61,25 @@ class SearchResultsPagingSource( checkNotNull(searchRequestId) { "Search request id is invalid" } - mediaProviderClient.fetchSearchResults( - searchRequestId, - pageKey, - pageSize, - contentResolver, - availableProviders, - configuration, - cancellationSignal, - ) + val searchResults = + mediaProviderClient.fetchSearchResults( + searchRequestId, + pageKey, + pageSize, + contentResolver, + availableProviders, + configuration, + cancellationSignal, + ) + + if (searchResults is LoadResult.Page) { + Log.d( + TAG, + "Received ${searchResults.data.count()} search results from MP for $searchRequestId", + ) + } + + searchResults } catch (e: Exception) { Log.e(TAG, "Could not fetch search results page from Media provider", e) LoadResult.Error(e) diff --git a/photopicker/src/com/android/photopicker/features/search/inject/SearchEmbeddedServiceModule.kt b/photopicker/src/com/android/photopicker/features/search/inject/SearchEmbeddedServiceModule.kt index 409a40c23..b50d72f19 100644 --- a/photopicker/src/com/android/photopicker/features/search/inject/SearchEmbeddedServiceModule.kt +++ b/photopicker/src/com/android/photopicker/features/search/inject/SearchEmbeddedServiceModule.kt @@ -63,6 +63,7 @@ class SearchEmbeddedServiceModule { configurationManager: ConfigurationManager, @Background scope: CoroutineScope, @Background dispatcher: CoroutineDispatcher, + mediaProviderClient: MediaProviderClient, notificationService: NotificationService, events: Events, ): SearchDataService { @@ -83,7 +84,7 @@ class SearchEmbeddedServiceModule { scope, dispatcher, notificationService, - MediaProviderClient(), + mediaProviderClient, events, ) return searchDataService diff --git a/photopicker/src/com/android/photopicker/features/search/model/SearchEnabledState.kt b/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchState.kt index f969e62d3..1718a21cb 100644 --- a/photopicker/src/com/android/photopicker/features/search/model/SearchEnabledState.kt +++ b/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchState.kt @@ -16,13 +16,18 @@ package com.android.photopicker.features.search.model -/** This represents the search enabled states the current profile could have. */ -enum class SearchEnabledState { +/** + * This represents valid global search states. + * + * Global search state refers to the search state of all user profiles available on the device. + */ +enum class GlobalSearchState() { /* Search is enabled for the current profile */ ENABLED, - /* Search is disabled in the current profile but enabled in other profiles */ + /* Search is disabled in the current profile but enabled in at least one of the + * other profiles */ ENABLED_IN_OTHER_PROFILES_ONLY, - /* Search is disabled in all profiles */ + /* Search is disabled in current profile and other profiles */ DISABLED, /* Either the state of the current profile is unknown, or the current profile has search * disabled and the state of other profile(s) is unknown. */ diff --git a/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchStateInfo.kt b/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchStateInfo.kt new file mode 100644 index 000000000..eb3326384 --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchStateInfo.kt @@ -0,0 +1,46 @@ +/* + * Copyright 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.photopicker.features.search.model + +/** Holds search state info for all user profiles on the device. */ +data class GlobalSearchStateInfo( + // Map of all available profiles to the provider authorities that have search feature + // enabled. If no providers have search enabled, tha value in map should be an empty string. + // If the information is unknown for a given profile, the value in map should be null. + val providersWithSearchEnabled: Map<Int, List<String>?>, + val currentUserId: Int, +) { + val state: GlobalSearchState = + when { + // Check if search is enabled in current profile + providersWithSearchEnabled[currentUserId]?.isNotEmpty() ?: false -> + GlobalSearchState.ENABLED + + // Check if search is enabled in any other profile + providersWithSearchEnabled.values.any { providers -> + providers?.isNotEmpty() ?: false + } -> GlobalSearchState.ENABLED_IN_OTHER_PROFILES_ONLY + + // Check if there is missing information + providersWithSearchEnabled.values.any { providers -> providers == null } -> + GlobalSearchState.UNKNOWN + + // If we have all information and search is not enabled in any profile, + // search is disabled. + else -> GlobalSearchState.DISABLED + } +} diff --git a/photopicker/src/com/android/photopicker/features/search/model/UserSearchState.kt b/photopicker/src/com/android/photopicker/features/search/model/UserSearchState.kt new file mode 100644 index 000000000..c9bfaf3d2 --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/search/model/UserSearchState.kt @@ -0,0 +1,31 @@ +/* + * Copyright 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.photopicker.features.search.model + +/** + * This represents valid user search states. + * + * User search state refers to the search state of the current selected profile in a Picker session. + */ +enum class UserSearchState() { + /* Search is enabled in the current profile */ + ENABLED, + /* Search is disabled in the current profile */ + DISABLED, + /* Search state for the current profile is unknown */ + UNKNOWN, +} diff --git a/photopicker/src/com/android/photopicker/features/search/model/UserSearchStateInfo.kt b/photopicker/src/com/android/photopicker/features/search/model/UserSearchStateInfo.kt new file mode 100644 index 000000000..885cbf218 --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/search/model/UserSearchStateInfo.kt @@ -0,0 +1,27 @@ +/* + * Copyright 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.photopicker.features.search.model + +/** Holds search state info for the current selected profile in a Picker session. */ +data class UserSearchStateInfo(val searchProviderAuthorities: List<String>?) { + val state: UserSearchState = + when { + searchProviderAuthorities == null -> UserSearchState.UNKNOWN + searchProviderAuthorities.isEmpty() -> UserSearchState.DISABLED + else -> UserSearchState.ENABLED + } +} diff --git a/photopicker/src/com/android/photopicker/inject/ActivityModule.kt b/photopicker/src/com/android/photopicker/inject/ActivityModule.kt index dc69be86a..d95d30733 100644 --- a/photopicker/src/com/android/photopicker/inject/ActivityModule.kt +++ b/photopicker/src/com/android/photopicker/inject/ActivityModule.kt @@ -194,6 +194,7 @@ class ActivityModule { @Background scope: CoroutineScope, @Background dispatcher: CoroutineDispatcher, userMonitor: UserMonitor, + mediaProviderClient: MediaProviderClient, notificationService: NotificationService, configurationManager: ConfigurationManager, featureManager: FeatureManager, @@ -212,7 +213,7 @@ class ActivityModule { scope, dispatcher, notificationService, - MediaProviderClient(), + mediaProviderClient, configurationManager.configuration, featureManager, appContext, @@ -311,7 +312,12 @@ class ActivityModule { @Provides @ActivityRetainedScoped - fun providePrefetchDataService(): PrefetchDataService { + fun providePrefetchDataService( + userMonitor: UserMonitor, + @ApplicationContext context: Context, + @Background backgroundDispatcher: CoroutineDispatcher, + mediaProviderClient: MediaProviderClient, + ): PrefetchDataService { if (!::prefetchDataService.isInitialized) { Log.d( @@ -319,7 +325,13 @@ class ActivityModule { "PrefetchDataService requested but not yet initialized. " + "Initializing PrefetchDataService.", ) - prefetchDataService = PrefetchDataServiceImpl() + prefetchDataService = + PrefetchDataServiceImpl( + mediaProviderClient, + userMonitor, + context, + backgroundDispatcher, + ) } return prefetchDataService } diff --git a/photopicker/src/com/android/photopicker/inject/ApplicationModule.kt b/photopicker/src/com/android/photopicker/inject/ApplicationModule.kt index cdadb5295..f9f3a45d8 100644 --- a/photopicker/src/com/android/photopicker/inject/ApplicationModule.kt +++ b/photopicker/src/com/android/photopicker/inject/ApplicationModule.kt @@ -22,6 +22,7 @@ import android.util.Log import com.android.photopicker.core.configuration.DeviceConfigProxy import com.android.photopicker.core.configuration.DeviceConfigProxyImpl import com.android.photopicker.core.network.NetworkMonitor +import com.android.photopicker.data.MediaProviderClient import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -78,6 +79,12 @@ class ApplicationModule { return DeviceConfigProxyImpl() } + /** Provider for [MediaProviderClient]. */ + @Provides + fun providerMediaProviderClient(): MediaProviderClient { + return MediaProviderClient() + } + /** * Provider for the [NetworkMonitor]. This is lazily initialized only when requested to save on * initialization costs of this module. @@ -93,7 +100,7 @@ class ApplicationModule { } else { Log.d( NetworkMonitor.TAG, - "NetworkMonitor requested, but not yet initialized. Initializing NetworkMonitor." + "NetworkMonitor requested, but not yet initialized. Initializing NetworkMonitor.", ) networkMonitor = NetworkMonitor(context, scope) return networkMonitor diff --git a/photopicker/src/com/android/photopicker/inject/EmbeddedServiceModule.kt b/photopicker/src/com/android/photopicker/inject/EmbeddedServiceModule.kt index fd87f4a86..8821d56bc 100644 --- a/photopicker/src/com/android/photopicker/inject/EmbeddedServiceModule.kt +++ b/photopicker/src/com/android/photopicker/inject/EmbeddedServiceModule.kt @@ -254,6 +254,7 @@ class EmbeddedServiceModule { @ApplicationContext appContext: Context, events: Events, processOwnerHandle: UserHandle, + mediaProviderClient: MediaProviderClient, ): DataService { if (!::dataService.isInitialized) { @@ -267,7 +268,7 @@ class EmbeddedServiceModule { scope, dispatcher, notificationService, - MediaProviderClient(), + mediaProviderClient, configurationManager.configuration, featureManager, appContext, @@ -389,7 +390,12 @@ class EmbeddedServiceModule { @Provides @SessionScoped - fun providePrefetchDataService(): PrefetchDataService { + fun providePrefetchDataService( + userMonitor: UserMonitor, + @ApplicationContext context: Context, + @Background backgroundDispatcher: CoroutineDispatcher, + mediaProviderClient: MediaProviderClient, + ): PrefetchDataService { if (!::prefetchDataService.isInitialized) { Log.d( @@ -397,7 +403,13 @@ class EmbeddedServiceModule { "PrefetchDataService requested but not yet initialized. " + "Initializing PrefetchDataService.", ) - prefetchDataService = PrefetchDataServiceImpl() + prefetchDataService = + PrefetchDataServiceImpl( + mediaProviderClient, + userMonitor, + context, + backgroundDispatcher, + ) } return prefetchDataService } diff --git a/photopicker/src/com/android/photopicker/util/MapOfDeferredWithTimeout.kt b/photopicker/src/com/android/photopicker/util/MapOfDeferredWithTimeout.kt index 776e59f60..11e992272 100644 --- a/photopicker/src/com/android/photopicker/util/MapOfDeferredWithTimeout.kt +++ b/photopicker/src/com/android/photopicker/util/MapOfDeferredWithTimeout.kt @@ -52,7 +52,7 @@ suspend fun <A, B> mapOfDeferredWithTimeout( result } } catch (e: RuntimeException) { - Log.e(TAG, "An error occurred in fetching result for key: $key") + Log.e(TAG, "An error occurred in fetching result for key: $key", e) null } } diff --git a/photopicker/tests/src/com/android/photopicker/data/DataServiceImplTest.kt b/photopicker/tests/src/com/android/photopicker/data/DataServiceImplTest.kt index d74cc8f7e..49540e5e4 100644 --- a/photopicker/tests/src/com/android/photopicker/data/DataServiceImplTest.kt +++ b/photopicker/tests/src/com/android/photopicker/data/DataServiceImplTest.kt @@ -76,8 +76,6 @@ import org.mockito.Mockito.verify @OptIn(ExperimentalCoroutinesApi::class) class DataServiceImplTest { - val testSessionId = generatePickerSessionId() - companion object { private fun createUserHandle(userId: Int = 0): UserHandle { val parcel = Parcel.obtain() @@ -191,7 +189,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -219,7 +217,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -358,7 +356,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -385,7 +383,7 @@ class DataServiceImplTest { this.backgroundScope, PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -1052,7 +1050,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -1080,7 +1078,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, diff --git a/photopicker/tests/src/com/android/photopicker/data/PrefetchDataServiceImplTest.kt b/photopicker/tests/src/com/android/photopicker/data/PrefetchDataServiceImplTest.kt new file mode 100644 index 000000000..02d1ceddf --- /dev/null +++ b/photopicker/tests/src/com/android/photopicker/data/PrefetchDataServiceImplTest.kt @@ -0,0 +1,361 @@ +/* + * 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 src.com.android.photopicker.data + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.content.pm.UserProperties +import android.os.Parcel +import android.os.UserHandle +import android.os.UserManager +import android.provider.MediaStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.modules.utils.build.SdkLevel +import com.android.photopicker.R +import com.android.photopicker.core.configuration.TestPhotopickerConfiguration +import com.android.photopicker.core.configuration.provideTestConfigurationFlow +import com.android.photopicker.core.user.UserMonitor +import com.android.photopicker.core.user.UserProfile +import com.android.photopicker.data.MediaProviderClient +import com.android.photopicker.data.PrefetchDataServiceImpl +import com.android.photopicker.data.TestMediaProvider +import com.android.photopicker.features.search.model.GlobalSearchState +import com.android.photopicker.util.test.mockSystemService +import com.android.photopicker.util.test.nonNullableEq +import com.android.photopicker.util.test.whenever +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.any +import org.mockito.Mockito.anyInt +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +public class PrefetchDataServiceImplTest { + @Mock private lateinit var mockPrimaryUserContext: Context + @Mock private lateinit var mockManagedUserContext: Context + @Mock private lateinit var mockUserManager: UserManager + @Mock private lateinit var mockPackageManager: PackageManager + @Mock private lateinit var mockResolveInfo: ResolveInfo + + private lateinit var testPrimaryUserContentProvider: TestMediaProvider + private lateinit var testManagedUserContentProvider: TestMediaProvider + private lateinit var testPrimaryUserContentResolver: ContentResolver + private lateinit var testManagedUserContentResolver: ContentResolver + + private val PLATFORM_PROVIDED_PROFILE_LABEL = "Platform Label" + + private val USER_HANDLE_PRIMARY: UserHandle + private val USER_ID_PRIMARY: Int = 0 + private val PRIMARY_PROFILE_BASE: UserProfile + + private val USER_HANDLE_MANAGED: UserHandle + private val USER_ID_MANAGED: Int = 10 + private val MANAGED_PROFILE_BASE: UserProfile + + init { + val parcel1 = Parcel.obtain() + parcel1.writeInt(USER_ID_PRIMARY) + parcel1.setDataPosition(0) + USER_HANDLE_PRIMARY = UserHandle(parcel1) + parcel1.recycle() + + PRIMARY_PROFILE_BASE = + UserProfile( + handle = USER_HANDLE_PRIMARY, + profileType = UserProfile.ProfileType.PRIMARY, + label = PLATFORM_PROVIDED_PROFILE_LABEL, + ) + + val parcel2 = Parcel.obtain() + parcel2.writeInt(USER_ID_MANAGED) + parcel2.setDataPosition(0) + USER_HANDLE_MANAGED = UserHandle(parcel2) + parcel2.recycle() + + MANAGED_PROFILE_BASE = + UserProfile( + handle = USER_HANDLE_MANAGED, + profileType = UserProfile.ProfileType.MANAGED, + label = PLATFORM_PROVIDED_PROFILE_LABEL, + ) + } + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + val resources = InstrumentationRegistry.getInstrumentation().getContext().getResources() + + testPrimaryUserContentProvider = TestMediaProvider() + testPrimaryUserContentResolver = ContentResolver.wrap(testPrimaryUserContentProvider) + testManagedUserContentProvider = TestMediaProvider() + testManagedUserContentResolver = ContentResolver.wrap(testManagedUserContentProvider) + + mockSystemService(mockPrimaryUserContext, UserManager::class.java) { mockUserManager } + mockSystemService(mockManagedUserContext, UserManager::class.java) { mockUserManager } + + whenever(mockPrimaryUserContext.packageManager) { mockPackageManager } + whenever(mockPrimaryUserContext.contentResolver) { testPrimaryUserContentResolver } + whenever(mockManagedUserContext.contentResolver) { testManagedUserContentResolver } + whenever( + mockPrimaryUserContext.createPackageContextAsUser( + any(), + anyInt(), + nonNullableEq(USER_HANDLE_PRIMARY), + ) + ) { + mockPrimaryUserContext + } + whenever( + mockPrimaryUserContext.createPackageContextAsUser( + any(), + anyInt(), + nonNullableEq(USER_HANDLE_MANAGED), + ) + ) { + mockManagedUserContext + } + whenever( + mockManagedUserContext.createPackageContextAsUser( + any(), + anyInt(), + nonNullableEq(USER_HANDLE_PRIMARY), + ) + ) { + mockPrimaryUserContext + } + whenever( + mockManagedUserContext.createPackageContextAsUser( + any(), + anyInt(), + nonNullableEq(USER_HANDLE_MANAGED), + ) + ) { + mockManagedUserContext + } + whenever( + mockPrimaryUserContext.createContextAsUser(nonNullableEq(USER_HANDLE_PRIMARY), anyInt()) + ) { + mockPrimaryUserContext + } + whenever( + mockPrimaryUserContext.createContextAsUser(nonNullableEq(USER_HANDLE_MANAGED), anyInt()) + ) { + mockManagedUserContext + } + whenever( + mockManagedUserContext.createContextAsUser(nonNullableEq(USER_HANDLE_PRIMARY), anyInt()) + ) { + mockPrimaryUserContext + } + whenever( + mockManagedUserContext.createContextAsUser(nonNullableEq(USER_HANDLE_MANAGED), anyInt()) + ) { + mockManagedUserContext + } + + // Initial setup state: Two profiles (Personal/Work), both enabled + whenever(mockUserManager.userProfiles) { listOf(USER_HANDLE_PRIMARY, USER_HANDLE_MANAGED) } + + // Default responses for relevant UserManager apis + whenever(mockUserManager.isQuietModeEnabled(USER_HANDLE_PRIMARY)) { false } + whenever(mockUserManager.isManagedProfile(USER_ID_PRIMARY)) { false } + whenever(mockUserManager.isQuietModeEnabled(USER_HANDLE_MANAGED)) { false } + whenever(mockUserManager.isManagedProfile(USER_ID_MANAGED)) { true } + whenever(mockUserManager.getProfileParent(USER_HANDLE_MANAGED)) { USER_HANDLE_PRIMARY } + + whenever(mockResolveInfo.isCrossProfileIntentForwarderActivity()) { true } + whenever(mockPackageManager.queryIntentActivities(any(Intent::class.java), anyInt())) { + listOf(mockResolveInfo) + } + + if (SdkLevel.isAtLeastV()) { + whenever(mockUserManager.getUserBadge()) { + resources.getDrawable(R.drawable.android, /* theme= */ null) + } + whenever(mockUserManager.getProfileLabel()) { PLATFORM_PROVIDED_PROFILE_LABEL } + whenever(mockUserManager.getUserProperties(USER_HANDLE_PRIMARY)) { + UserProperties.Builder().build() + } + // By default, allow managed profile to be available + whenever(mockUserManager.getUserProperties(USER_HANDLE_MANAGED)) { + UserProperties.Builder() + .setCrossProfileContentSharingStrategy( + UserProperties.CROSS_PROFILE_CONTENT_SHARING_DELEGATE_FROM_PARENT + ) + .build() + } + } + } + + @Test + fun testGetGlobalSearchStateEnabled() = runTest { + testManagedUserContentProvider.searchProviders = listOf() + + val userMonitor = + UserMonitor( + mockPrimaryUserContext, + provideTestConfigurationFlow( + scope = this.backgroundScope, + defaultConfiguration = + TestPhotopickerConfiguration.build { + action(MediaStore.ACTION_PICK_IMAGES) + intent(Intent(MediaStore.ACTION_PICK_IMAGES)) + }, + ), + this.backgroundScope, + StandardTestDispatcher(this.testScheduler), + USER_HANDLE_PRIMARY, + ) + + val prefetchDataService = + PrefetchDataServiceImpl( + MediaProviderClient(), + userMonitor, + mockPrimaryUserContext, + StandardTestDispatcher(this.testScheduler), + ) + + val globalSearchState = prefetchDataService.getGlobalSearchState() + + assertWithMessage("Global search state is not enabled") + .that(globalSearchState) + .isEqualTo(GlobalSearchState.ENABLED) + } + + @Test + fun testGetGlobalSearchStateEnabledInOtherProfiles() = runTest { + val userMonitor = + UserMonitor( + mockPrimaryUserContext, + provideTestConfigurationFlow( + scope = this.backgroundScope, + defaultConfiguration = + TestPhotopickerConfiguration.build { + action(MediaStore.ACTION_PICK_IMAGES) + intent(Intent(MediaStore.ACTION_PICK_IMAGES)) + }, + ), + this.backgroundScope, + StandardTestDispatcher(this.testScheduler), + USER_HANDLE_PRIMARY, + ) + + val prefetchDataService = + PrefetchDataServiceImpl( + MediaProviderClient(), + userMonitor, + mockPrimaryUserContext, + StandardTestDispatcher(this.testScheduler), + ) + + testPrimaryUserContentProvider.searchProviders = listOf() + val globalSearchState1 = prefetchDataService.getGlobalSearchState() + assertWithMessage("Global search state is not enabled in other profiles") + .that(globalSearchState1) + .isEqualTo(GlobalSearchState.ENABLED_IN_OTHER_PROFILES_ONLY) + + testPrimaryUserContentProvider.searchProviders = null + val globalSearchState2 = prefetchDataService.getGlobalSearchState() + assertWithMessage("Global search state is not enabled in other profiles") + .that(globalSearchState2) + .isEqualTo(GlobalSearchState.ENABLED_IN_OTHER_PROFILES_ONLY) + } + + @Test + fun testGetGlobalSearchStateUnknown() = runTest { + val userMonitor = + UserMonitor( + mockPrimaryUserContext, + provideTestConfigurationFlow( + scope = this.backgroundScope, + defaultConfiguration = + TestPhotopickerConfiguration.build { + action(MediaStore.ACTION_PICK_IMAGES) + intent(Intent(MediaStore.ACTION_PICK_IMAGES)) + }, + ), + this.backgroundScope, + StandardTestDispatcher(this.testScheduler), + USER_HANDLE_PRIMARY, + ) + + val prefetchDataService = + PrefetchDataServiceImpl( + MediaProviderClient(), + userMonitor, + mockPrimaryUserContext, + StandardTestDispatcher(this.testScheduler), + ) + + testPrimaryUserContentProvider.searchProviders = listOf() + testManagedUserContentProvider.searchProviders = null + val globalSearchState = prefetchDataService.getGlobalSearchState() + + assertWithMessage("Global search state is not enabled in other profiles") + .that(globalSearchState) + .isEqualTo(GlobalSearchState.UNKNOWN) + } + + @Test + fun testGetGlobalSearchStateDisabled() = runTest { + testPrimaryUserContentProvider.searchProviders = listOf() + testManagedUserContentProvider.searchProviders = listOf() + + val userMonitor = + UserMonitor( + mockPrimaryUserContext, + provideTestConfigurationFlow( + scope = this.backgroundScope, + defaultConfiguration = + TestPhotopickerConfiguration.build { + action(MediaStore.ACTION_PICK_IMAGES) + intent(Intent(MediaStore.ACTION_PICK_IMAGES)) + }, + ), + this.backgroundScope, + StandardTestDispatcher(this.testScheduler), + USER_HANDLE_PRIMARY, + ) + + val prefetchDataService = + PrefetchDataServiceImpl( + MediaProviderClient(), + userMonitor, + mockPrimaryUserContext, + StandardTestDispatcher(this.testScheduler), + ) + + val globalSearchState = prefetchDataService.getGlobalSearchState() + + assertWithMessage("Global search state is not enabled in other profiles") + .that(globalSearchState) + .isEqualTo(GlobalSearchState.DISABLED) + } +} diff --git a/photopicker/tests/src/com/android/photopicker/data/TestMediaProvider.kt b/photopicker/tests/src/com/android/photopicker/data/TestMediaProvider.kt index cab01bcc8..5c96c8a80 100644 --- a/photopicker/tests/src/com/android/photopicker/data/TestMediaProvider.kt +++ b/photopicker/tests/src/com/android/photopicker/data/TestMediaProvider.kt @@ -23,8 +23,10 @@ import android.os.Bundle import android.os.CancellationSignal import android.test.mock.MockContentProvider import androidx.core.os.bundleOf +import com.android.photopicker.data.model.CategoryType import com.android.photopicker.data.model.CollectionInfo import com.android.photopicker.data.model.Group +import com.android.photopicker.data.model.Icon import com.android.photopicker.data.model.Media import com.android.photopicker.data.model.MediaSource import com.android.photopicker.data.model.Provider @@ -103,6 +105,20 @@ val DEFAULT_SEARCH_SUGGESTIONS: List<SearchSuggestion> = ), ) +val DEFAULT_CATEGORY: Group.Category = + createCategory(CategoryType.PEOPLE_AND_PETS, DEFAULT_PROVIDERS[0].authority) + +val DEFAULT_CATEGORIES_AND_ALBUMS: List<Group> = + listOf( + createAlbum("Favorites"), + createAlbum("Downloads"), + DEFAULT_CATEGORY, + createAlbum("CloudAlbum"), + ) + +val DEFAULT_MEDIA_SETS: List<Group.MediaSet> = + listOf(createMediaSet("1"), createMediaSet("2"), createMediaSet("3")) + fun createMediaImage(pickerId: Long): Media { return Media.Image( mediaId = UUID.randomUUID().toString(), @@ -122,11 +138,33 @@ fun createAlbum(albumId: String): Group.Album { return Group.Album( id = albumId, pickerId = albumId.hashCode().toLong(), - authority = "authority", + authority = DEFAULT_PROVIDERS[0].authority, dateTakenMillisLong = Long.MAX_VALUE, displayName = albumId, - coverUri = Uri.parse("content://media/picker/authority/media/$albumId"), - coverMediaSource = MediaSource.LOCAL, + coverUri = Uri.parse("content://test_authority/$albumId"), + coverMediaSource = DEFAULT_PROVIDERS[0].mediaSource, + ) +} + +fun createCategory(type: CategoryType, authority: String): Group.Category { + return Group.Category( + id = "test_id_" + type.name, + pickerId = 0, + authority = authority, + displayName = type.name, + categoryType = type, + icons = listOf(Icon(Uri.parse("content://test_authority/id"), MediaSource.LOCAL)), + isLeafCategory = true, + ) +} + +fun createMediaSet(mediaSetId: String): Group.MediaSet { + return Group.MediaSet( + id = mediaSetId, + pickerId = mediaSetId.hashCode().toLong(), + authority = DEFAULT_PROVIDERS[0].authority, + displayName = mediaSetId, + icon = Icon(Uri.parse("content://test_authority/$mediaSetId"), MediaSource.LOCAL), ) } @@ -138,6 +176,10 @@ class TestMediaProvider( var albumMedia: Map<String, List<Media>> = DEFAULT_ALBUM_MEDIA, var searchRequestId: Int = DEFAULT_SEARCH_REQUEST_ID, var searchSuggestions: List<SearchSuggestion> = DEFAULT_SEARCH_SUGGESTIONS, + var searchProviders: List<Provider>? = DEFAULT_PROVIDERS, + var parentCategory: Group.Category = DEFAULT_CATEGORY, + var categoriesAndAlbums: List<Group> = DEFAULT_CATEGORIES_AND_ALBUMS, + var mediaSets: List<Group.MediaSet> = DEFAULT_MEDIA_SETS, ) : MockContentProvider() { var lastRefreshMediaRequest: Bundle? = null var TEST_GRANTS_COUNT = 2 @@ -149,19 +191,24 @@ class TestMediaProvider( cancellationSignal: CancellationSignal?, ): Cursor? { return when (uri.lastPathSegment) { - "available_providers" -> getAvailableProviders() - "collection_info" -> getCollectionInfo() - "media" -> getMedia() - "album" -> getAlbums() - "media_grants_count" -> fetchMediaGrantsCount() - "pre_selection" -> fetchFilteredMedia(queryArgs) - "search_suggestions" -> getSearchSuggestions() + AVAILABLE_PROVIDERS_PATH_SEGMENT -> getAvailableProviders() + COLLECTION_INFO_SEGMENT -> getCollectionInfo() + MEDIA_PATH_SEGMENT -> getMedia() + ALBUM_PATH_SEGMENT -> getAlbums() + MEDIA_GRANTS_COUNT_PATH_SEGMENT -> fetchMediaGrantsCount() + PRE_SELECTION_URI_PATH_SEGMENT -> fetchFilteredMedia(queryArgs) + SEARCH_SUGGESTIONS_PATH_SEGMENT -> getSearchSuggestions() + CATEGORIES_PATH_SEGMENT -> getCategoriesAndAlbums() + MEDIA_SETS_PATH_SEGMENT -> getMediaSets() + MEDIA_SET_CONTENTS_PATH_SEGMENT -> getMedia() else -> { val pathSegments: MutableList<String> = uri.getPathSegments() - if (pathSegments.size == 4 && pathSegments[2].equals("album")) { + if (pathSegments.size == 4 && pathSegments[2].equals(ALBUM_PATH_SEGMENT)) { // Album media query return getAlbumMedia(pathSegments[3]) - } else if (pathSegments.size == 4 && pathSegments[2].equals("search_media")) { + } else if ( + pathSegments.size == 4 && pathSegments[2].equals(SEARCH_MEDIA_PATH_SEGMENT) + ) { // Search results media query return getMedia() } else { @@ -173,13 +220,22 @@ class TestMediaProvider( override fun call(authority: String, method: String, arg: String?, extras: Bundle?): Bundle? { return when (method) { - "picker_media_init" -> { + MediaProviderClient.MEDIA_INIT_CALL_METHOD -> { initMedia(extras) null } - "picker_internal_search_media_init" -> { + MediaProviderClient.SEARCH_REQUEST_INIT_CALL_METHOD -> { bundleOf(MediaProviderClient.SEARCH_REQUEST_ID to searchRequestId) } + MediaProviderClient.GET_SEARCH_PROVIDERS_CALL_METHOD -> + bundleOf( + MediaProviderClient.SEARCH_PROVIDER_AUTHORITIES to + if (searchProviders == null) null + else + arrayListOf<String>().apply { + searchProviders?.map { it.authority }?.toCollection(this) + } + ) else -> throw UnsupportedOperationException("Could not recognize method $method") } } @@ -389,4 +445,86 @@ class TestMediaProvider( } return cursor } + + private fun getCategoriesAndAlbums(): Cursor { + val cursor = + MatrixCursor( + arrayOf( + MediaProviderClient.GroupResponse.MEDIA_GROUP.key, + MediaProviderClient.GroupResponse.GROUP_ID.key, + MediaProviderClient.GroupResponse.PICKER_ID.key, + MediaProviderClient.GroupResponse.DISPLAY_NAME.key, + MediaProviderClient.GroupResponse.AUTHORITY.key, + MediaProviderClient.GroupResponse.UNWRAPPED_COVER_URI.key, + MediaProviderClient.GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_1.key, + MediaProviderClient.GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_2.key, + MediaProviderClient.GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_3.key, + MediaProviderClient.GroupResponse.CATEGORY_TYPE.key, + MediaProviderClient.GroupResponse.IS_LEAF_CATEGORY.key, + ) + ) + categoriesAndAlbums.forEach { group -> + when (group) { + is Group.Album -> + cursor.addRow( + arrayOf( + MediaProviderClient.GroupType.ALBUM.name, + group.id, + group.pickerId.toString(), + group.displayName, + group.authority, + group.coverUri.toString(), + /* additional uri */ null, + /* additional uri */ null, + /* additional uri */ null, + /* category type */ null, + /* is leaf category */ null, + ) + ) + is Group.Category -> + cursor.addRow( + arrayOf( + MediaProviderClient.GroupType.CATEGORY.name, + group.id, + group.pickerId.toString(), + group.displayName, + group.authority, + group.icons.getOrNull(0)?.getLoadableUri()?.toString(), + group.icons.getOrNull(1)?.getLoadableUri()?.toString(), + group.icons.getOrNull(2)?.getLoadableUri()?.toString(), + group.icons.getOrNull(3)?.getLoadableUri()?.toString(), + group.categoryType.key, + if (group.isLeafCategory) 1 else null, + ) + ) + else -> {} + } + } + return cursor + } + + private fun getMediaSets(): Cursor { + val cursor = + MatrixCursor( + arrayOf( + MediaProviderClient.GroupResponse.GROUP_ID.key, + MediaProviderClient.GroupResponse.PICKER_ID.key, + MediaProviderClient.GroupResponse.DISPLAY_NAME.key, + MediaProviderClient.GroupResponse.AUTHORITY.key, + MediaProviderClient.GroupResponse.UNWRAPPED_COVER_URI.key, + ) + ) + mediaSets.forEach { + cursor.addRow( + arrayOf( + it.id, + it.pickerId.toString(), + it.displayName, + it.authority, + it.icon.getLoadableUri().toString(), + ) + ) + } + return cursor + } } diff --git a/photopicker/tests/src/com/android/photopicker/data/TestPrefetchDataService.kt b/photopicker/tests/src/com/android/photopicker/data/TestPrefetchDataService.kt index c66d25635..a9c377cc5 100644 --- a/photopicker/tests/src/com/android/photopicker/data/TestPrefetchDataService.kt +++ b/photopicker/tests/src/com/android/photopicker/data/TestPrefetchDataService.kt @@ -16,12 +16,12 @@ package com.android.photopicker.data -import com.android.photopicker.features.search.model.SearchEnabledState +import com.android.photopicker.features.search.model.GlobalSearchState class TestPrefetchDataService() : PrefetchDataService { - var searchEnabledState = SearchEnabledState.ENABLED + var globalSearchState = GlobalSearchState.ENABLED - override suspend fun getSearchState(): SearchEnabledState { - return searchEnabledState + override suspend fun getGlobalSearchState(): GlobalSearchState { + return globalSearchState } } diff --git a/photopicker/tests/src/com/android/photopicker/data/paging/MediaProviderClientTest.kt b/photopicker/tests/src/com/android/photopicker/data/paging/MediaProviderClientTest.kt index cfc7b50e5..3f26d98c2 100644 --- a/photopicker/tests/src/com/android/photopicker/data/paging/MediaProviderClientTest.kt +++ b/photopicker/tests/src/com/android/photopicker/data/paging/MediaProviderClientTest.kt @@ -27,11 +27,13 @@ import androidx.test.filters.SmallTest import com.android.photopicker.core.configuration.PhotopickerConfiguration import com.android.photopicker.core.configuration.TestPhotopickerConfiguration import com.android.photopicker.core.events.generatePickerSessionId +import com.android.photopicker.data.DEFAULT_PROVIDERS import com.android.photopicker.data.DEFAULT_SEARCH_REQUEST_ID import com.android.photopicker.data.DEFAULT_SEARCH_SUGGESTIONS import com.android.photopicker.data.MediaProviderClient import com.android.photopicker.data.TestMediaProvider import com.android.photopicker.data.model.Group +import com.android.photopicker.data.model.GroupPageKey import com.android.photopicker.data.model.Media import com.android.photopicker.data.model.MediaPageKey import com.android.photopicker.data.model.MediaSource @@ -487,4 +489,135 @@ class MediaProviderClientTest { assertThat(searchSuggestions[index]).isEqualTo(DEFAULT_SEARCH_SUGGESTIONS[index]) } } + + @Test + fun testFetchSearchProvidersWithAvailableProvidersKnown() = runTest { + val mediaProviderClient = MediaProviderClient() + val localProvider = + Provider( + authority = "local_authority", + mediaSource = MediaSource.LOCAL, + uid = 0, + displayName = "", + ) + val cloudProvider = + Provider( + authority = "cloud_authority", + mediaSource = MediaSource.REMOTE, + uid = 0, + displayName = "", + ) + val testContentProvider: TestMediaProvider = + TestMediaProvider(searchProviders = listOf(localProvider, cloudProvider)) + val testContentResolver: ContentResolver = ContentResolver.wrap(testContentProvider) + + val searchProviderAuthorities = + mediaProviderClient.fetchSearchProviderAuthorities( + resolver = testContentResolver, + availableProviders = listOf(localProvider), + ) + + assertThat(searchProviderAuthorities).isEqualTo(listOf(localProvider.authority)) + } + + @Test + fun testFetchSearchProviders() = runTest { + val mediaProviderClient = MediaProviderClient() + val testContentResolver: ContentResolver = ContentResolver.wrap(testContentProvider) + val searchProviderAuthorities = + mediaProviderClient.fetchSearchProviderAuthorities(resolver = testContentResolver) + + assertThat(searchProviderAuthorities) + .isEqualTo(DEFAULT_PROVIDERS.map { it.authority }.toList()) + } + + @Test + fun testFetchCategories() = runTest { + val mediaProviderClient = MediaProviderClient() + + val categoriesLoadResult: LoadResult<GroupPageKey, Group> = + mediaProviderClient.fetchCategoriesAndAlbums( + pageKey = GroupPageKey(), + pageSize = 5, + contentResolver = testContentResolver, + availableProviders = testContentProvider.providers, + parentCategoryId = null, + config = + PhotopickerConfiguration( + action = MediaStore.ACTION_PICK_IMAGES, + sessionId = sessionId, + ), + CancellationSignal(), + ) + + assertThat(categoriesLoadResult is LoadResult.Page).isTrue() + + val categoriesAndAlbums: List<Group> = (categoriesLoadResult as LoadResult.Page).data + + val expectedCategoriesAndAlbums = testContentProvider.categoriesAndAlbums + assertThat(categoriesAndAlbums.count()).isEqualTo(expectedCategoriesAndAlbums.count()) + for (index in expectedCategoriesAndAlbums.indices) { + assertThat(categoriesAndAlbums[index]).isEqualTo(expectedCategoriesAndAlbums[index]) + } + } + + @Test + fun testFetchMediaSets() = runTest { + val mediaProviderClient = MediaProviderClient() + + val mediaSetsLoadResult: LoadResult<GroupPageKey, Group.MediaSet> = + mediaProviderClient.fetchMediaSets( + pageKey = GroupPageKey(), + pageSize = 5, + contentResolver = testContentResolver, + availableProviders = testContentProvider.providers, + parentCategory = testContentProvider.parentCategory, + config = + PhotopickerConfiguration( + action = MediaStore.ACTION_PICK_IMAGES, + sessionId = sessionId, + ), + cancellationSignal = CancellationSignal(), + ) + + assertThat(mediaSetsLoadResult is LoadResult.Page).isTrue() + + val mediaSets: List<Group.MediaSet> = (mediaSetsLoadResult as LoadResult.Page).data + + val expectedMediaSets = testContentProvider.mediaSets + assertThat(mediaSets.count()).isEqualTo(expectedMediaSets.count()) + for (index in expectedMediaSets.indices) { + assertThat(mediaSets[index]).isEqualTo(expectedMediaSets[index]) + } + } + + @Test + fun testFetchMediaSetContents() = runTest { + val mediaProviderClient = MediaProviderClient() + + val mediaSetContentsLoadResult: LoadResult<MediaPageKey, Media> = + mediaProviderClient.fetchMediaSetContents( + pageKey = MediaPageKey(), + pageSize = 5, + contentResolver = testContentResolver, + availableProviders = testContentProvider.providers, + parentMediaSet = testContentProvider.mediaSets[0], + config = + PhotopickerConfiguration( + action = MediaStore.ACTION_PICK_IMAGES, + sessionId = sessionId, + ), + cancellationSignal = CancellationSignal(), + ) + + assertThat(mediaSetContentsLoadResult is LoadResult.Page).isTrue() + + val media: List<Media> = (mediaSetContentsLoadResult as LoadResult.Page).data + + val expectedMedia = testContentProvider.media + assertThat(media.count()).isEqualTo(expectedMedia.count()) + for (index in expectedMedia.indices) { + assertThat(media[index]).isEqualTo(expectedMedia[index]) + } + } } diff --git a/photopicker/tests/src/com/android/photopicker/features/categorygrid/data/CategoryDataServiceImplTest.kt b/photopicker/tests/src/com/android/photopicker/features/categorygrid/data/CategoryDataServiceImplTest.kt new file mode 100644 index 000000000..c5656e27e --- /dev/null +++ b/photopicker/tests/src/com/android/photopicker/features/categorygrid/data/CategoryDataServiceImplTest.kt @@ -0,0 +1,354 @@ +/* + * 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 src.com.android.photopicker.features.categorygrid.data + +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.os.CancellationSignal +import android.os.Parcel +import android.os.UserHandle +import androidx.paging.PagingSource +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.photopicker.core.configuration.provideTestConfigurationFlow +import com.android.photopicker.core.events.Events +import com.android.photopicker.core.features.FeatureManager +import com.android.photopicker.core.user.UserProfile +import com.android.photopicker.core.user.UserStatus +import com.android.photopicker.data.DataService +import com.android.photopicker.data.DataServiceImpl +import com.android.photopicker.data.MediaProviderClient +import com.android.photopicker.data.TestMediaProvider +import com.android.photopicker.data.TestNotificationServiceImpl +import com.android.photopicker.data.TestPrefetchDataService +import com.android.photopicker.data.model.Group +import com.android.photopicker.data.model.GroupPageKey +import com.android.photopicker.data.model.Media +import com.android.photopicker.data.model.MediaPageKey +import com.android.photopicker.data.model.MediaSource +import com.android.photopicker.data.model.Provider +import com.android.photopicker.features.categorygrid.data.CategoryDataService +import com.android.photopicker.features.categorygrid.data.CategoryDataServiceImpl +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock + +@SmallTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class CategoryDataServiceImplTest { + + companion object { + private fun createUserHandle(userId: Int = 0): UserHandle { + val parcel = Parcel.obtain() + parcel.writeInt(userId) + parcel.setDataPosition(0) + val userHandle = UserHandle(parcel) + parcel.recycle() + return userHandle + } + + private val userProfilePrimary: UserProfile = + UserProfile(handle = createUserHandle(0), profileType = UserProfile.ProfileType.PRIMARY) + } + + private lateinit var testFeatureManager: FeatureManager + private lateinit var testContentProvider: TestMediaProvider + private lateinit var testContentResolver: ContentResolver + private lateinit var notificationService: TestNotificationServiceImpl + private lateinit var mediaProviderClient: MediaProviderClient + private lateinit var mockContext: Context + private lateinit var mockPackageManager: PackageManager + private lateinit var events: Events + private lateinit var userStatusFlow: MutableStateFlow<UserStatus> + + @Before + fun setup() { + val scope = TestScope() + testContentProvider = TestMediaProvider() + testContentResolver = ContentResolver.wrap(testContentProvider) + notificationService = TestNotificationServiceImpl() + mediaProviderClient = MediaProviderClient() + mockContext = mock(Context::class.java) + mockPackageManager = mock(PackageManager::class.java) + val userStatus = + UserStatus( + activeUserProfile = userProfilePrimary, + allProfiles = listOf(userProfilePrimary), + activeContentResolver = testContentResolver, + ) + testFeatureManager = + FeatureManager( + configuration = provideTestConfigurationFlow(scope = scope.backgroundScope), + scope = scope, + prefetchDataService = TestPrefetchDataService(), + registeredFeatures = setOf(), + coreEventsConsumed = setOf(), + coreEventsProduced = setOf(), + ) + userStatusFlow = MutableStateFlow(userStatus) + events = + Events( + scope = scope.backgroundScope, + provideTestConfigurationFlow(scope.backgroundScope), + testFeatureManager, + ) + } + + @Test + fun testCategoryPagingSourceCacheReuse() = runTest { + val dataService = getDataService(this) + val categoryDataService = getCategoryDataService(this, dataService) + + advanceTimeBy(100) + + val cancellationSignal = CancellationSignal() + val firstCategoryAndAlbumPagingSource: PagingSource<GroupPageKey, Group> = + categoryDataService.getCategories() + assertThat(firstCategoryAndAlbumPagingSource.invalid).isFalse() + + // Check that the older paging source was cached and is reused. + val secondCategoryAndAlbumPagingSource: PagingSource<GroupPageKey, Group> = + categoryDataService.getCategories(cancellationSignal = cancellationSignal) + assertThat(secondCategoryAndAlbumPagingSource).isEqualTo(firstCategoryAndAlbumPagingSource) + assertThat(cancellationSignal.isCanceled()).isFalse() + + firstCategoryAndAlbumPagingSource.invalidate() + assertThat(cancellationSignal.isCanceled()).isTrue() + } + + @Test + fun testCategoryPagingSourceInvalidation() = runTest { + val dataService = getDataService(this) + val categoryDataService = getCategoryDataService(this, dataService) + + val emissions = mutableListOf<List<Provider>>() + this.backgroundScope.launch { dataService.availableProviders.toList(emissions) } + advanceTimeBy(100) + + assertThat(emissions.count()).isEqualTo(1) + + val cancellationSignal = CancellationSignal() + val firstCategoryAndAlbumPagingSource: PagingSource<GroupPageKey, Group> = + categoryDataService.getCategories(cancellationSignal = cancellationSignal) + assertThat(firstCategoryAndAlbumPagingSource.invalid).isFalse() + assertThat(cancellationSignal.isCanceled()).isFalse() + + updateActiveContentResolver() + advanceTimeBy(1000) + + // Since the active user has changed, this should trigger a re-fetch of the active + // providers. + assertThat(emissions.count()).isEqualTo(2) + + // Check that the old PagingSource has been invalidated. + assertThat(firstCategoryAndAlbumPagingSource.invalid).isTrue() + + // Check that the CancellationSignal has been marked as cancelled. + assertThat(cancellationSignal.isCanceled()).isTrue() + + // Check that the new PagingSource instance is valid. + val secondCategoryAndAlbumPagingSource: PagingSource<GroupPageKey, Group> = + categoryDataService.getCategories() + assertThat(secondCategoryAndAlbumPagingSource.invalid).isFalse() + } + + @Test + fun testMediaSetsPagingSourceCacheReuse() = runTest { + val dataService = getDataService(this) + val categoryDataService = getCategoryDataService(this, dataService) + + advanceTimeBy(100) + + val cancellationSignal = CancellationSignal() + val firstMediaSetsPagingSource: PagingSource<GroupPageKey, Group.MediaSet> = + categoryDataService.getMediaSets(testContentProvider.parentCategory) + assertThat(firstMediaSetsPagingSource.invalid).isFalse() + + // Check that the older paging source was cached and is reused. + val secondMediaSetsPagingSource: PagingSource<GroupPageKey, Group.MediaSet> = + categoryDataService.getMediaSets(testContentProvider.parentCategory, cancellationSignal) + assertThat(secondMediaSetsPagingSource).isEqualTo(firstMediaSetsPagingSource) + assertThat(cancellationSignal.isCanceled()).isFalse() + + firstMediaSetsPagingSource.invalidate() + assertThat(cancellationSignal.isCanceled()).isTrue() + } + + @Test + fun testMediaSetsPagingSourceInvalidation() = runTest { + val dataService = getDataService(this) + val categoryDataService = getCategoryDataService(this, dataService) + + val emissions = mutableListOf<List<Provider>>() + this.backgroundScope.launch { dataService.availableProviders.toList(emissions) } + advanceTimeBy(100) + + assertThat(emissions.count()).isEqualTo(1) + + val cancellationSignal = CancellationSignal() + val firstMediaSetsPagingSource: PagingSource<GroupPageKey, Group.MediaSet> = + categoryDataService.getMediaSets(testContentProvider.parentCategory, cancellationSignal) + assertThat(firstMediaSetsPagingSource.invalid).isFalse() + assertThat(cancellationSignal.isCanceled()).isFalse() + + updateActiveContentResolver() + advanceTimeBy(1000) + + // Since the active user has changed, this should trigger a re-fetch of the active + // providers. + assertThat(emissions.count()).isEqualTo(2) + + // Check that the old PagingSource has been invalidated. + assertThat(firstMediaSetsPagingSource.invalid).isTrue() + + // Check that the CancellationSignal has been marked as cancelled. + assertThat(cancellationSignal.isCanceled()).isTrue() + + // Check that the new PagingSource instance is valid. + val secondMediaSetsPagingSource: PagingSource<GroupPageKey, Group.MediaSet> = + categoryDataService.getMediaSets(testContentProvider.parentCategory) + assertThat(secondMediaSetsPagingSource.invalid).isFalse() + } + + @Test + fun testMediaSetContentsPagingSourceCacheReuse() = runTest { + val dataService = getDataService(this) + val categoryDataService = getCategoryDataService(this, dataService) + + advanceTimeBy(100) + + val cancellationSignal = CancellationSignal() + val firstMediaSetContentsPagingSource: PagingSource<MediaPageKey, Media> = + categoryDataService.getMediaSetContents(testContentProvider.mediaSets[0]) + assertThat(firstMediaSetContentsPagingSource.invalid).isFalse() + + // Check that the older paging source was cached and is reused. + val secondMediaSetContentsPagingSource: PagingSource<MediaPageKey, Media> = + categoryDataService.getMediaSetContents( + testContentProvider.mediaSets[0].copy(), + cancellationSignal, + ) + assertThat(secondMediaSetContentsPagingSource).isEqualTo(firstMediaSetContentsPagingSource) + assertThat(cancellationSignal.isCanceled()).isFalse() + + firstMediaSetContentsPagingSource.invalidate() + assertThat(cancellationSignal.isCanceled()).isTrue() + } + + @Test + fun testMediaSetContentsPagingSourceInvalidation() = runTest { + val dataService = getDataService(this) + val categoryDataService = getCategoryDataService(this, dataService) + + val emissions = mutableListOf<List<Provider>>() + this.backgroundScope.launch { dataService.availableProviders.toList(emissions) } + advanceTimeBy(100) + + assertThat(emissions.count()).isEqualTo(1) + + val cancellationSignal = CancellationSignal() + val firstMediaSetContentsPagingSource: PagingSource<MediaPageKey, Media> = + categoryDataService.getMediaSetContents( + testContentProvider.mediaSets[0], + cancellationSignal, + ) + assertThat(firstMediaSetContentsPagingSource.invalid).isFalse() + assertThat(cancellationSignal.isCanceled()).isFalse() + + updateActiveContentResolver() + advanceTimeBy(1000) + + // Since the active user has changed, this should trigger a re-fetch of the active + // providers. + assertThat(emissions.count()).isEqualTo(2) + + // Check that the old PagingSource has been invalidated. + assertThat(firstMediaSetContentsPagingSource.invalid).isTrue() + + // Check that the CancellationSignal has been marked as cancelled. + assertThat(cancellationSignal.isCanceled()).isTrue() + + // Check that the new PagingSource instance is valid. + val secondMediaSetContentsPagingSource: PagingSource<MediaPageKey, Media> = + categoryDataService.getMediaSetContents(testContentProvider.mediaSets[0]) + assertThat(secondMediaSetContentsPagingSource.invalid).isFalse() + } + + private fun getDataService(scope: TestScope): DataService { + return DataServiceImpl( + userStatus = userStatusFlow, + scope = scope.backgroundScope, + notificationService = notificationService, + mediaProviderClient = mediaProviderClient, + dispatcher = StandardTestDispatcher(scope.testScheduler), + config = provideTestConfigurationFlow(scope.backgroundScope), + featureManager = testFeatureManager, + appContext = mockContext, + events = events, + processOwnerHandle = userProfilePrimary.handle, + ) + } + + private fun getCategoryDataService( + scope: TestScope, + dataService: DataService, + ): CategoryDataService { + return CategoryDataServiceImpl( + dataService = dataService, + config = provideTestConfigurationFlow(scope.backgroundScope), + scope = scope.backgroundScope, + notificationService = notificationService, + mediaProviderClient = mediaProviderClient, + dispatcher = StandardTestDispatcher(scope.testScheduler), + events = events, + ) + } + + private fun updateActiveContentResolver() { + val updatedContentProvider = TestMediaProvider() + val updatedContentResolver: ContentResolver = ContentResolver.wrap(updatedContentProvider) + updatedContentProvider.providers = + mutableListOf( + Provider( + authority = "local_authority", + mediaSource = MediaSource.LOCAL, + uid = 0, + displayName = "", + ), + Provider( + authority = "cloud_authority", + mediaSource = MediaSource.REMOTE, + uid = 0, + displayName = "", + ), + ) + userStatusFlow.update { it.copy(activeContentResolver = updatedContentResolver) } + } +} diff --git a/photopicker/tests/src/com/android/photopicker/features/categorygrid/data/TestCategoryDataServiceImpl.kt b/photopicker/tests/src/com/android/photopicker/features/categorygrid/data/TestCategoryDataServiceImpl.kt index e39b7b7a3..04f84382b 100644 --- a/photopicker/tests/src/com/android/photopicker/features/categorygrid/data/TestCategoryDataServiceImpl.kt +++ b/photopicker/tests/src/com/android/photopicker/features/categorygrid/data/TestCategoryDataServiceImpl.kt @@ -26,13 +26,7 @@ import com.android.photopicker.features.categorygrid.data.CategoryDataService class TestCategoryDataServiceImpl : CategoryDataService { override fun getCategories( - cancellationSignal: CancellationSignal? - ): PagingSource<GroupPageKey, Group> { - TODO("Not yet implemented") - } - - override fun getCategories( - parentCategory: Group.Category, + parentCategory: Group.Category?, cancellationSignal: CancellationSignal?, ): PagingSource<GroupPageKey, Group> { TODO("Not yet implemented") diff --git a/photopicker/tests/src/com/android/photopicker/features/search/SearchDataServiceImplTest.kt b/photopicker/tests/src/com/android/photopicker/features/search/SearchDataServiceImplTest.kt index 92da23322..11c08e9c4 100644 --- a/photopicker/tests/src/com/android/photopicker/features/search/SearchDataServiceImplTest.kt +++ b/photopicker/tests/src/com/android/photopicker/features/search/SearchDataServiceImplTest.kt @@ -19,6 +19,7 @@ package com.android.photopicker.features.search import android.content.ContentResolver import android.content.Context import android.content.pm.PackageManager +import android.net.Uri import android.os.CancellationSignal import android.os.Parcel import android.os.UserHandle @@ -65,6 +66,9 @@ import org.mockito.Mockito.mock class SearchDataServiceImplTest { companion object { + private val searchMediaUpdateUri = + Uri.parse("content://media/picker_internal/v2/search_media/update") + private fun createUserHandle(userId: Int = 0): UserHandle { val parcel = Parcel.obtain() parcel.writeInt(userId) @@ -202,4 +206,67 @@ class SearchDataServiceImplTest { searchDataService.getSearchResults(searchText) assertThat(secondSearchResultsPagingSource.invalid).isFalse() } + + @Test + fun testOnUpdateSearchResultsNotification() = runTest { + val userStatusFlow: MutableStateFlow<UserStatus> = MutableStateFlow(userStatus) + events = + Events( + scope = this.backgroundScope, + provideTestConfigurationFlow(this.backgroundScope), + testFeatureManager, + ) + + val dataService: DataService = + DataServiceImpl( + userStatus = userStatusFlow, + scope = this.backgroundScope, + notificationService = notificationService, + mediaProviderClient = mediaProviderClient, + dispatcher = StandardTestDispatcher(this.testScheduler), + config = provideTestConfigurationFlow(this.backgroundScope), + featureManager = testFeatureManager, + appContext = mockContext, + events = events, + processOwnerHandle = userProfilePrimary.handle, + ) + + val searchDataService: SearchDataService = + SearchDataServiceImpl( + dataService = dataService, + userStatus = userStatusFlow, + photopickerConfiguration = provideTestConfigurationFlow(this.backgroundScope), + scope = this.backgroundScope, + notificationService = notificationService, + mediaProviderClient = mediaProviderClient, + dispatcher = StandardTestDispatcher(this.testScheduler), + events = events, + ) + + advanceTimeBy(100) + + val searchText: String = "search_query" + val cancellationSignal = CancellationSignal() + val firstSearchResultsPagingSource: PagingSource<MediaPageKey, Media> = + searchDataService.getSearchResults(searchText = searchText, cancellationSignal) + assertThat(firstSearchResultsPagingSource.invalid).isFalse() + + val searchResultsUpdateUri: Uri = + searchMediaUpdateUri + .buildUpon() + .apply { appendPath(testContentProvider.searchRequestId.toString()) } + .build() + // Send an update notification + notificationService.dispatchChangeToObservers(searchResultsUpdateUri) + advanceTimeBy(100) + + // Check that the first media paging source was marked as invalid + assertThat(firstSearchResultsPagingSource.invalid).isTrue() + + // Check that the a new PagingSource instance was created which is still valid + val secondSearchResultsPagingSource: PagingSource<MediaPageKey, Media> = + searchDataService.getSearchResults(searchText = searchText, cancellationSignal) + assertThat(secondSearchResultsPagingSource).isNotEqualTo(firstSearchResultsPagingSource) + assertThat(secondSearchResultsPagingSource.invalid).isFalse() + } } diff --git a/photopicker/tests/src/com/android/photopicker/features/search/SearchFeatureTest.kt b/photopicker/tests/src/com/android/photopicker/features/search/SearchFeatureTest.kt index a0aa0fdba..4454646aa 100644 --- a/photopicker/tests/src/com/android/photopicker/features/search/SearchFeatureTest.kt +++ b/photopicker/tests/src/com/android/photopicker/features/search/SearchFeatureTest.kt @@ -59,7 +59,7 @@ import com.android.photopicker.core.features.PrefetchResultKey import com.android.photopicker.core.selection.Selection import com.android.photopicker.data.model.Media import com.android.photopicker.features.PhotopickerFeatureBaseTest -import com.android.photopicker.features.search.model.SearchEnabledState +import com.android.photopicker.features.search.model.GlobalSearchState import com.android.photopicker.inject.PhotopickerTestModule import com.android.photopicker.tests.HiltTestActivity import com.android.providers.media.flags.Flags @@ -139,7 +139,7 @@ class SearchFeatureTest : PhotopickerFeatureBaseTest() { PrefetchResultKey.SEARCH_STATE to runBlocking { async { - return@async SearchEnabledState.ENABLED + return@async GlobalSearchState.ENABLED } } ) diff --git a/photopicker/tests/src/com/android/photopicker/features/search/TestSearchDataServiceImpl.kt b/photopicker/tests/src/com/android/photopicker/features/search/TestSearchDataServiceImpl.kt index f89933d7f..7254541ed 100644 --- a/photopicker/tests/src/com/android/photopicker/features/search/TestSearchDataServiceImpl.kt +++ b/photopicker/tests/src/com/android/photopicker/features/search/TestSearchDataServiceImpl.kt @@ -23,9 +23,9 @@ import com.android.photopicker.data.model.Media import com.android.photopicker.data.model.MediaPageKey import com.android.photopicker.data.paging.FakeInMemoryMediaPagingSource import com.android.photopicker.features.search.data.SearchDataService -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchSuggestion import com.android.photopicker.features.search.model.SearchSuggestionType +import com.android.photopicker.features.search.model.UserSearchStateInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,8 +37,8 @@ class TestSearchDataServiceImpl() : SearchDataService { var mediaSetSize: Int = FakeInMemoryMediaPagingSource.DEFAULT_SIZE var mediaList: List<Media>? = null - override val isSearchEnabled: StateFlow<SearchEnabledState> = - MutableStateFlow(SearchEnabledState.ENABLED) + override val userSearchStateInfo: StateFlow<UserSearchStateInfo> = + MutableStateFlow(UserSearchStateInfo(listOf("test_provider"))) override suspend fun getSearchSuggestions( prefix: String, diff --git a/photopicker/tests/src/com/android/photopicker/inject/PhotopickerTestModule.kt b/photopicker/tests/src/com/android/photopicker/inject/PhotopickerTestModule.kt index 264f09021..411934ffb 100644 --- a/photopicker/tests/src/com/android/photopicker/inject/PhotopickerTestModule.kt +++ b/photopicker/tests/src/com/android/photopicker/inject/PhotopickerTestModule.kt @@ -39,6 +39,7 @@ import com.android.photopicker.core.selection.SelectionStrategy import com.android.photopicker.core.selection.SelectionStrategy.Companion.determineSelectionStrategy import com.android.photopicker.core.user.UserMonitor import com.android.photopicker.data.DataService +import com.android.photopicker.data.MediaProviderClient import com.android.photopicker.data.PrefetchDataService import com.android.photopicker.data.TestDataServiceImpl import com.android.photopicker.data.TestPrefetchDataService @@ -233,6 +234,12 @@ abstract class PhotopickerTestModule(val options: TestOptions = TestOptions.Buil @Singleton @Provides + fun createMediaProviderClient(): MediaProviderClient { + return MediaProviderClient() + } + + @Singleton + @Provides fun createPrefetchDataService(): PrefetchDataService { return TestPrefetchDataService() } diff --git a/src/com/android/providers/media/FilesOwnershipUtils.java b/src/com/android/providers/media/FilesOwnershipUtils.java index 04eee5c19..a0e2ee5fb 100644 --- a/src/com/android/providers/media/FilesOwnershipUtils.java +++ b/src/com/android/providers/media/FilesOwnershipUtils.java @@ -109,7 +109,7 @@ public class FilesOwnershipUtils { int rowsAffected = db.update(FILES_TABLE_NAME, contentValues, whereClause, whereArgs.toArray(String[]::new)); - Log.d(TAG, "Set owner package name to null for " + rowsAffected + " items for " + Log.i(TAG, "Set owner package name to null for " + rowsAffected + " items for " + "packages " + Arrays.toString(packages)); db.execSQL("DROP TABLE " + TEMP_TABLE_NAME); diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java index 69736a15a..bc3127c5b 100644 --- a/src/com/android/providers/media/MediaProvider.java +++ b/src/com/android/providers/media/MediaProvider.java @@ -3981,7 +3981,8 @@ public class MediaProvider extends ContentProvider { mPickerSyncController.getCloudProvider(), mPickerDataLayer); } if (table == PICKER_INTERNAL_V2) { - return PickerUriResolverV2.query(getContext().getApplicationContext(), uri, queryArgs); + return PickerUriResolverV2.query( + getContext().getApplicationContext(), uri, queryArgs, signal); } final DatabaseHelper helper = getDatabaseForUri(uri); @@ -7154,6 +7155,9 @@ public class MediaProvider extends ContentProvider { initMediaSets(extras); return new Bundle(); } + case MediaStore.PICKER_GET_SEARCH_PROVIDERS_CALL: { + return getPickerSearchProviders(); + } case MediaStore.PICKER_TRANSCODE_CALL: { return getResultForPickerTranscode(extras); } @@ -7233,6 +7237,7 @@ public class MediaProvider extends ContentProvider { int userId; List<Uri> uris = null; String[] packageNames; + int packageUid; if (checkPermissionShell(caller)) { // If the caller is the shell, the accepted parameter is EXTRA_PACKAGE_NAME // (as string). @@ -7241,7 +7246,14 @@ public class MediaProvider extends ContentProvider { "Missing required extras arguments: EXTRA_URI or" + " EXTRA_PACKAGE_NAME"); } - packageNames = new String[]{extras.getString(Intent.EXTRA_PACKAGE_NAME)}; + String packageName = extras.getString(Intent.EXTRA_PACKAGE_NAME); + packageNames = new String[]{packageName}; + try { + packageUid = mPackageManager.getPackageUid(packageName, 0); + } catch (NameNotFoundException e) { + Log.e(TAG, "No packageUid found for packageName " + packageName, e); + throw new RuntimeException(e); + } // Uris are not a requirement for revoke all call if (!isCallForRevokeAll) { uris = List.of(Uri.parse(extras.getString(MediaStore.EXTRA_URI))); @@ -7251,7 +7263,7 @@ public class MediaProvider extends ContentProvider { userId = UserHandle.myUserId(); } else if (checkPermissionSelf(caller) || isCallerPhotoPicker()) { final PackageManager pm = getContext().getPackageManager(); - final int packageUid = extras.getInt(Intent.EXTRA_UID); + packageUid = extras.getInt(Intent.EXTRA_UID); packageNames = pm.getPackagesForUid(packageUid); // Get the userId from packageUid as the initiator could be a cloned app, which // accesses Media via MP of its parent user and Binder's callingUid reflects @@ -7267,12 +7279,12 @@ public class MediaProvider extends ContentProvider { getSecurityExceptionMessage("revoke media grants")); } - if (isCallForRevokeAll && !isOwnedPhotosEnabled(caller)) { + if (isCallForRevokeAll && !isOwnedPhotosEnabled(packageUid)) { mMediaGrants.removeAllMediaGrantsForPackages(packageNames, "user de-selections", userId); } else if (uris != null) { mMediaGrants.removeMediaGrantsForPackage(packageNames, uris, userId); - if (isOwnedPhotosEnabled(caller)) { + if (isOwnedPhotosEnabled(packageUid)) { mFilesOwnershipUtils.removeOwnerPackageNameForUris(packageNames, uris, userId); } @@ -7704,6 +7716,18 @@ public class MediaProvider extends ContentProvider { PickerDataLayerV2.triggerMediaSyncForMediaSet(extras, getContext()); } + @Nullable + private Bundle getPickerSearchProviders() { + Log.i(TAG, "Received picker internal call to get available search providers."); + if (!checkPermissionShell(Binder.getCallingUid()) + && !checkPermissionSelf(Binder.getCallingUid()) + && !isCallerPhotoPicker()) { + throw new SecurityException( + getSecurityExceptionMessage("Picker get search providers")); + } + return PickerDataLayerV2.getSearchProviders(getContext()); + } + /** * Checks if the caller has the permission to handle picker search media init. If not, * this method throws a security exception. diff --git a/src/com/android/providers/media/backupandrestore/BackupExecutor.java b/src/com/android/providers/media/backupandrestore/BackupExecutor.java index 55b731020..fee5b4590 100644 --- a/src/com/android/providers/media/backupandrestore/BackupExecutor.java +++ b/src/com/android/providers/media/backupandrestore/BackupExecutor.java @@ -22,6 +22,7 @@ import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils import static com.android.providers.media.backupandrestore.BackupAndRestoreUtils.BACKUP_DIRECTORY_NAME; 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.flags.Flags.enableBackupAndRestore; import static com.android.providers.media.util.Logging.TAG; import android.annotation.SuppressLint; @@ -82,7 +83,6 @@ public final class BackupExecutor { public BackupExecutor(Context context, DatabaseHelper databaseHelper) { mContext = context; mExternalDatabaseHelper = databaseHelper; - mLevelDBInstance = LevelDBManager.getInstance(getBackupFilePath()); } /** @@ -92,10 +92,13 @@ public final class BackupExecutor { * 3. Updates the new backed up generation number */ public void doBackup(CancellationSignal signal) { - if (!SdkLevel.isAtLeastS()) { + if (!SdkLevel.isAtLeastS() || !enableBackupAndRestore()) { return; } + if (mLevelDBInstance == null) { + mLevelDBInstance = LevelDBManager.getInstance(getBackupFilePath()); + } final long lastBackedUpGenerationNumberFromLevelDb = getLastBackedUpGenerationNumber(); final long currentDbGenerationNumber = mExternalDatabaseHelper.runWithoutTransaction( DatabaseHelper::getGeneration); @@ -269,7 +272,7 @@ public final class BackupExecutor { * Removes entry for given file path from Backup. */ public void deleteBackupForPath(String path) { - if (path != null) { + if (enableBackupAndRestore() && path != null && mLevelDBInstance != null) { mLevelDBInstance.delete(path); } } diff --git a/src/com/android/providers/media/photopicker/SearchState.java b/src/com/android/providers/media/photopicker/SearchState.java index e6cbeabf3..9e41bb04c 100644 --- a/src/com/android/providers/media/photopicker/SearchState.java +++ b/src/com/android/providers/media/photopicker/SearchState.java @@ -23,6 +23,7 @@ import android.content.pm.PackageManager; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.android.modules.utils.build.SdkLevel; import com.android.providers.media.ConfigStore; @@ -45,9 +46,13 @@ public class SearchState { */ public boolean isCloudSearchEnabled( @NonNull Context context, - @NonNull String cloudAuthority) { + @Nullable String cloudAuthority) { requireNonNull(context); - requireNonNull(cloudAuthority); + + if (cloudAuthority == null) { + Log.d(TAG, "Cloud authority received is null. Cloud search is disabled"); + return false; + } if (!isSearchFeatureEnabled(context)) { Log.d(TAG, "Search feature is disabled on the device."); @@ -111,7 +116,7 @@ public class SearchState { } if (!Flags.enablePhotopickerSearch()) { - Log.d(TAG, "Search feature is disabled."); + Log.d(TAG, "Search feature flag is disabled."); return false; } diff --git a/src/com/android/providers/media/photopicker/sync/PickerSearchProviderClient.java b/src/com/android/providers/media/photopicker/sync/PickerSearchProviderClient.java index a51501634..ad73da140 100644 --- a/src/com/android/providers/media/photopicker/sync/PickerSearchProviderClient.java +++ b/src/com/android/providers/media/photopicker/sync/PickerSearchProviderClient.java @@ -84,9 +84,20 @@ public class PickerSearchProviderClient { queryArgs.putString(CloudMediaProviderContract.EXTRA_PAGE_TOKEN, resumePageToken); queryArgs.putInt(CloudMediaProviderContract.EXTRA_SORT_ORDER, sortOrder); - return mContext.getContentResolver().query( + Log.d(TAG, "Search results query sent to CMP: " + queryArgs); + + final Cursor cursor = mContext.getContentResolver().query( getCloudUriFromPath(CloudMediaProviderContract.URI_PATH_SEARCH_MEDIA), - null, queryArgs, cancellationSignal); + null, queryArgs, null); + + if (cursor == null) { + Log.d(TAG, "Search results response from the CMP is null."); + + } else { + Log.d(TAG, "Search results received from the CMP: " + cursor.getCount() + + " extras: " + cursor.getExtras()); + } + return cursor; } /** @@ -99,9 +110,21 @@ public class PickerSearchProviderClient { final Bundle queryArgs = new Bundle(); queryArgs.putString(CloudMediaProviderContract.KEY_PREFIX_TEXT, requireNonNull(prefixText)); queryArgs.putInt(CloudMediaProviderContract.EXTRA_PAGE_SIZE, limit); - return mContext.getContentResolver().query( + + Log.d(TAG, "Search suggestions query sent to CMP: " + queryArgs); + + final Cursor cursor = mContext.getContentResolver().query( getCloudUriFromPath(CloudMediaProviderContract.URI_PATH_SEARCH_SUGGESTION), - null, queryArgs, cancellationSignal); + null, queryArgs, null); + + if (cursor == null) { + Log.d(TAG, "Search suggestions response from the CMP is null."); + + } else { + Log.d(TAG, "Search suggestions received from the CMP: " + cursor.getCount() + + " extras: " + cursor.getExtras()); + } + return cursor; } /** @@ -116,9 +139,21 @@ public class PickerSearchProviderClient { queryArgs = new Bundle(); } queryArgs.putString(CloudMediaProviderContract.KEY_PARENT_CATEGORY_ID, parentCategoryId); - return mContext.getContentResolver().query( + + Log.d(TAG, "Categories query sent to CMP: " + queryArgs); + + final Cursor cursor = mContext.getContentResolver().query( getCloudUriFromPath(CloudMediaProviderContract.URI_PATH_MEDIA_CATEGORY), - null, queryArgs, cancellationSignal); + null, queryArgs, null); + + if (cursor == null) { + Log.d(TAG, "Categories response from the CMP is null."); + + } else { + Log.d(TAG, "Categories received from the CMP: " + cursor.getCount() + + " extras: " + cursor.getExtras()); + } + return cursor; } /** @@ -134,9 +169,21 @@ public class PickerSearchProviderClient { queryArgs.putString(CloudMediaProviderContract.EXTRA_PAGE_TOKEN, nextPageToken); queryArgs.putInt(CloudMediaProviderContract.EXTRA_PAGE_SIZE, pageSize); queryArgs.putStringArray(Intent.EXTRA_MIME_TYPES, mimeTypes); - return mContext.getContentResolver().query( + + Log.d(TAG, "Media sets query sent to CMP: " + queryArgs); + + final Cursor cursor = mContext.getContentResolver().query( getCloudUriFromPath(CloudMediaProviderContract.URI_PATH_MEDIA_SET), - null, queryArgs, cancellationSignal); + null, queryArgs, null); + + if (cursor == null) { + Log.d(TAG, "Media sets response from the CMP is null."); + + } else { + Log.d(TAG, "Media sets received from the CMP: " + cursor.getCount() + + " extras: " + cursor.getExtras()); + } + return cursor; } /** @@ -158,9 +205,20 @@ public class PickerSearchProviderClient { queryArgs.putInt(CloudMediaProviderContract.EXTRA_SORT_ORDER, sortOrder); queryArgs.putStringArray(Intent.EXTRA_MIME_TYPES, mimeTypes); - return mContext.getContentResolver().query( + Log.d(TAG, "Media set content query sent to CMP: " + queryArgs); + + final Cursor cursor = mContext.getContentResolver().query( getCloudUriFromPath(CloudMediaProviderContract.URI_PATH_MEDIA_IN_MEDIA_SET), - null, queryArgs, cancellationSignal); + null, queryArgs, null); + + if (cursor == null) { + Log.d(TAG, "Media set contents response from the CMP is null."); + + } else { + Log.d(TAG, "Media set contents received from the CMP: " + cursor.getCount() + + " extras: " + cursor.getExtras()); + } + return cursor; } private Uri getCloudUriFromPath(String uriPath) { @@ -185,6 +243,7 @@ public class PickerSearchProviderClient { final CloudMediaProviderContract.Capabilities capabilities = response.getParcelable(EXTRA_PROVIDER_CAPABILITIES); requireNonNull(capabilities); + Log.d(TAG, "Capabilities received from CMP: " + capabilities); return capabilities; } catch (RuntimeException e) { diff --git a/src/com/android/providers/media/photopicker/sync/SearchResultsSyncWorker.java b/src/com/android/providers/media/photopicker/sync/SearchResultsSyncWorker.java index abc135d43..85f6fd181 100644 --- a/src/com/android/providers/media/photopicker/sync/SearchResultsSyncWorker.java +++ b/src/com/android/providers/media/photopicker/sync/SearchResultsSyncWorker.java @@ -46,6 +46,7 @@ import androidx.work.WorkerParameters; import com.android.providers.media.photopicker.PickerSyncController; import com.android.providers.media.photopicker.util.exceptions.RequestObsoleteException; +import com.android.providers.media.photopicker.v2.PickerNotificationSender; 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; @@ -69,6 +70,7 @@ public class SearchResultsSyncWorker extends Worker { public static final String SYNC_COMPLETE_RESUME_KEY = "SYNCED"; private final Context mContext; private final CancellationSignal mCancellationSignal; + private boolean mMarkedSyncWorkAsComplete = false; /** * Creates an instance of the {@link Worker}. @@ -190,7 +192,13 @@ public class SearchResultsSyncWorker extends Worker { // Mark sync as completed after getting the first page to start returning // search results to the UI. - markSearchResultsSyncAsComplete(syncSource, getId()); + if (mMarkedSyncWorkAsComplete) { + PickerNotificationSender + .notifySearchResultsChange(mContext, searchRequestId); + } else { + markSearchResultsSyncAsComplete(syncSource, getId()); + mMarkedSyncWorkAsComplete = true; + } } } } finally { diff --git a/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2.java b/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2.java index 819e94d8f..14b509cba 100644 --- a/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2.java +++ b/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2.java @@ -118,7 +118,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; /** - * This class handles Photo Picker content queries.\ + * This class handles Photo Picker content queries. */ public class PickerDataLayerV2 { private static final String TAG = "PickerDataLayerV2"; @@ -414,20 +414,23 @@ public class PickerDataLayerV2 { // Add Pinned album and categories to the list of cursors in the order in which they // should be displayed. Note that pinned albums can only be local and merged albums. + long index = 0; for (Pair<MediaGroup, String> mediaGroup: PINNED_CATEGORIES_AND_ALBUMS_ORDER) { final Cursor cursor; - switch (mediaGroup.first) { case ALBUM: final String albumId = mediaGroup.second; if (MERGED_ALBUMS.contains(albumId)) { - final Cursor albumsCursor = PickerMediaDatabaseUtil.getMergedAlbumsCursor( + final Cursor mergedAlbumCursor = + PickerMediaDatabaseUtil.getMergedAlbumsCursor( albumId, appContext, queryArgs, database, effectiveLocalAuthority, effectiveCloudAuthority); - cursor = MediaGroupCursorUtils.getMediaGroupCursorForAlbums(albumsCursor); + cursor = MediaGroupCursorUtils.getMediaGroupCursorForAlbums( + mergedAlbumCursor, index); } else if (LOCAL_ALBUMS.contains(albumId)) { - final Cursor albumCursor = localAlbums.getOrDefault(albumId, null); - cursor = MediaGroupCursorUtils.getMediaGroupCursorForAlbums(albumCursor); + final Cursor localAlbumCursor = localAlbums.getOrDefault(albumId, null); + cursor = MediaGroupCursorUtils.getMediaGroupCursorForAlbums( + localAlbumCursor, index); } else { Log.e(TAG, "Could not recognize pinned album id, skipping it : " + albumId); cursor = null; @@ -438,7 +441,7 @@ public class PickerDataLayerV2 { switch (mediaGroup.second) { case CloudMediaProviderContract.MEDIA_CATEGORY_TYPE_PEOPLE_AND_PETS: cursor = MediaGroupCursorUtils.getMediaGroupCursorForCategories( - categories, effectiveCloudAuthority); + categories, effectiveCloudAuthority, index); break; default: Log.e(TAG, "Could not recognize pinned category type, skipping it : " @@ -452,7 +455,10 @@ public class PickerDataLayerV2 { cursor = null; } - allMediaGroupCursors.add(cursor); + if (cursor != null) { + index += cursor.getCount(); + allMediaGroupCursors.add(cursor); + } } // Add cloud albums at the end. @@ -462,7 +468,7 @@ public class PickerDataLayerV2 { final Cursor cloudAlbumsCursor = getCloudAlbumsCursor(appContext, query, effectiveLocalAuthority, effectiveCloudAuthority); allMediaGroupCursors.add( - MediaGroupCursorUtils.getMediaGroupCursorForAlbums(cloudAlbumsCursor)); + MediaGroupCursorUtils.getMediaGroupCursorForAlbums(cloudAlbumsCursor, index)); } catch (RuntimeException ex) { Log.w(TAG, "Cloud provider exception while fetching cloud albums cursor", ex); } @@ -678,7 +684,7 @@ public class PickerDataLayerV2 { try { cloudSearchSuggestions = cloudSuggestionsFuture.get( - /* timeout */ 300, TimeUnit.MILLISECONDS); + /* timeout */ 1500, TimeUnit.MILLISECONDS); cloudSuggestionsFuture.thenApplyAsync( (suggestions) -> maybeCacheSearchSuggestions(query, suggestions), executor); @@ -1444,7 +1450,6 @@ public class PickerDataLayerV2 { @NonNull Bundle extras, @NonNull Context appContext, @NonNull WorkManager workManager) { requireNonNull(workManager); - MediaSetsSyncRequestParams mediaSetsSyncRequestParams = new MediaSetsSyncRequestParams(extras); final Set<String> providers = new HashSet<>( @@ -1475,6 +1480,36 @@ public class PickerDataLayerV2 { } /** + * @param context the application context. + * @return a bundle with the list of available provider authorities that support the + * search feature. If no providers are available, return an empty list in the bundle. + */ + @NonNull + public static Bundle getSearchProviders(@NonNull Context context) { + Log.d(TAG, "Calculating available search providers."); + + requireNonNull(context); + + // Check the state of cloud and local search. + final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow(); + final String cloudProvider = syncController.getCloudProviderOrDefault(null); + final boolean isCloudSearchEnabled = + syncController.getSearchState().isCloudSearchEnabled(context, cloudProvider); + final boolean isLocalSearchEnabled = syncController.getSearchState().isLocalSearchEnabled(); + + // Prepare a bundle response with the result. + final ArrayList<String> searchProviderAuthorities = new ArrayList<>(); + if (isCloudSearchEnabled) searchProviderAuthorities.add(cloudProvider); + if (isLocalSearchEnabled) searchProviderAuthorities.add(syncController.getLocalProvider()); + + final Bundle result = new Bundle(); + result.putStringArrayList( + PickerSQLConstants.EXTRA_SEARCH_PROVIDER_AUTHORITIES, searchProviderAuthorities); + Log.d(TAG, "Available search providers are: " + result); + return result; + } + + /** * Schedules MediaSets sync for both local and cloud provider if the corresponding * providers implement Categories. * @param appContext The application context @@ -1634,7 +1669,7 @@ public class PickerDataLayerV2 { @NonNull private static Bundle getSearchRequestInitResponse(int searchRequestId) { final Bundle response = new Bundle(); - response.putInt("search_request_id", searchRequestId); + response.putInt(PickerSQLConstants.EXTRA_SEARCH_REQUEST_ID, searchRequestId); return response; } } diff --git a/src/com/android/providers/media/photopicker/v2/PickerNotificationSender.java b/src/com/android/providers/media/photopicker/v2/PickerNotificationSender.java index 3fe9770db..ecdc0e0cb 100644 --- a/src/com/android/providers/media/photopicker/v2/PickerNotificationSender.java +++ b/src/com/android/providers/media/photopicker/v2/PickerNotificationSender.java @@ -21,6 +21,7 @@ import static com.android.providers.media.photopicker.v2.PickerUriResolverV2.AVA import static com.android.providers.media.photopicker.v2.PickerUriResolverV2.MEDIA_PATH_SEGMENT; import static com.android.providers.media.photopicker.v2.PickerUriResolverV2.PICKER_INTERNAL_PATH_SEGMENT; import static com.android.providers.media.photopicker.v2.PickerUriResolverV2.PICKER_V2_PATH_SEGMENT; +import static com.android.providers.media.photopicker.v2.PickerUriResolverV2.SEARCH_RESULT_MEDIA_PATH_SEGMENT; import static com.android.providers.media.photopicker.v2.PickerUriResolverV2.UPDATE_PATH_SEGMENT; import static java.util.Objects.requireNonNull; @@ -72,6 +73,15 @@ public class PickerNotificationSender { .appendPath(UPDATE_PATH_SEGMENT) .build(); + private static final Uri SEARCH_RESULTS_UPDATE_URI = new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(MediaStore.AUTHORITY) + .appendPath(PICKER_INTERNAL_PATH_SEGMENT) + .appendPath(PICKER_V2_PATH_SEGMENT) + .appendPath(SEARCH_RESULT_MEDIA_PATH_SEGMENT) + .appendPath(UPDATE_PATH_SEGMENT) + .build(); + /** * Send media update notification to the registered {@link android.database.ContentObserver}-s. * @param context The application context. @@ -126,6 +136,22 @@ public class PickerNotificationSender { } } + /** + * Send search results update notification to the registered + * {@link android.database.ContentObserver}-s. + * @param context The application context. + * @param searchRequestId Search request ID corresponding for which the search results + * have updated. + */ + public static void notifySearchResultsChange( + @NonNull Context context, + @NonNull int searchRequestId) { + Log.d(TAG, "Sending a notification for search results update " + searchRequestId); + context.getContentResolver().notifyChange( + getSearchResultsUpdateUri(searchRequestId), + /* observer= */ null); + } + private static Uri getAlbumMediaUpdateUri( @NonNull String albumAuthority, @NonNull String albumId) { @@ -135,4 +161,11 @@ public class PickerNotificationSender { .appendPath(requireNonNull(albumId)) .build(); } + + private static Uri getSearchResultsUpdateUri(@NonNull int searchRequestId) { + return SEARCH_RESULTS_UPDATE_URI + .buildUpon() + .appendPath(Integer.toString(searchRequestId)) + .build(); + } } diff --git a/src/com/android/providers/media/photopicker/v2/PickerUriResolverV2.java b/src/com/android/providers/media/photopicker/v2/PickerUriResolverV2.java index 120a8142f..cb2ec7135 100644 --- a/src/com/android/providers/media/photopicker/v2/PickerUriResolverV2.java +++ b/src/com/android/providers/media/photopicker/v2/PickerUriResolverV2.java @@ -23,6 +23,7 @@ import android.content.UriMatcher; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; +import android.os.CancellationSignal; import android.provider.MediaStore; import androidx.annotation.IntDef; @@ -41,12 +42,14 @@ public class PickerUriResolverV2 { public static final String COLLECTION_INFO_PATH_SEGMENT = "collection_info"; public static final String MEDIA_PATH_SEGMENT = "media"; public static final String ALBUM_PATH_SEGMENT = "album"; + public static final String SEARCH_RESULT_MEDIA_PATH_SEGMENT = "search_media"; public static final String UPDATE_PATH_SEGMENT = "update"; private static final String MEDIA_GRANTS_COUNT_PATH_SEGMENT = "media_grants_count"; private static final String PREVIEW_PATH_SEGMENT = "preview"; private static final String PRE_SELECTION_PATH_SEGMENT = "pre_selection"; - private static final String SEARCH_RESULT_MEDIA_PATH_SEGMENT = "search_media"; private static final String SEARCH_SUGGESTIONS_PATH_SEGMENT = "search_suggestions"; + private static final String CATEGORIES_PATH_SEGMENT = "categories"; + private static final String MEDIA_SETS_PATH_SEGMENT = "media_sets"; static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); @@ -60,6 +63,8 @@ public class PickerUriResolverV2 { static final int PICKER_INTERNAL_PRE_SELECTION = 8; static final int PICKER_INTERNAL_SEARCH_MEDIA = 9; static final int PICKER_INTERNAL_SEARCH_SUGGESTIONS = 10; + static final int PICKER_INTERNAL_CATEGORIES = 11; + static final int PICKER_INTERNAL_MEDIA_SETS = 12; @Retention(RetentionPolicy.SOURCE) @IntDef({ @@ -74,6 +79,8 @@ public class PickerUriResolverV2 { PICKER_INTERNAL_PRE_SELECTION, PICKER_INTERNAL_SEARCH_MEDIA, PICKER_INTERNAL_SEARCH_SUGGESTIONS, + PICKER_INTERNAL_CATEGORIES, + PICKER_INTERNAL_MEDIA_SETS }) private @interface PickerQuery {} @@ -113,6 +120,12 @@ public class PickerUriResolverV2 { sUriMatcher.addURI(MediaStore.AUTHORITY, BASE_PICKER_PATH + SEARCH_SUGGESTIONS_PATH_SEGMENT, PICKER_INTERNAL_SEARCH_SUGGESTIONS); + sUriMatcher.addURI(MediaStore.AUTHORITY, + BASE_PICKER_PATH + CATEGORIES_PATH_SEGMENT, + PICKER_INTERNAL_CATEGORIES); + sUriMatcher.addURI(MediaStore.AUTHORITY, + BASE_PICKER_PATH + MEDIA_SETS_PATH_SEGMENT, + PICKER_INTERNAL_MEDIA_SETS); } /** @@ -123,7 +136,8 @@ public class PickerUriResolverV2 { public static Cursor query( @NonNull Context appContext, @NonNull Uri uri, - @Nullable Bundle queryArgs) { + @Nullable Bundle queryArgs, + @Nullable CancellationSignal cancellationSignal) { @PickerQuery final int query = sUriMatcher.match(uri); @@ -160,8 +174,14 @@ public class PickerUriResolverV2 { return PickerDataLayerV2.querySearchSuggestions( appContext, requireNonNull(queryArgs), - null - ); + cancellationSignal); + case PICKER_INTERNAL_CATEGORIES: + return PickerDataLayerV2.queryCategoriesAndAlbums( + appContext, + requireNonNull(queryArgs), + cancellationSignal); + case PICKER_INTERNAL_MEDIA_SETS: + return PickerDataLayerV2.queryMediaSets(requireNonNull(queryArgs)); default: throw new UnsupportedOperationException("Could not recognize content URI " + uri); } diff --git a/src/com/android/providers/media/photopicker/v2/model/MediaInMediaSetSyncRequestParams.java b/src/com/android/providers/media/photopicker/v2/model/MediaInMediaSetSyncRequestParams.java index ee7f3304d..9e296ec63 100644 --- a/src/com/android/providers/media/photopicker/v2/model/MediaInMediaSetSyncRequestParams.java +++ b/src/com/android/providers/media/photopicker/v2/model/MediaInMediaSetSyncRequestParams.java @@ -23,22 +23,23 @@ import androidx.annotation.NonNull; import java.util.Objects; public class MediaInMediaSetSyncRequestParams { + public static final String KEY_PARENT_MEDIA_SET_AUTHORITY = "media_set_picker_authority"; + public static final String KEY_PARENT_MEDIA_SET_PICKER_ID = "media_set_picker_id"; private final String mAuthority; - private final String mMediaSetPickerId; + private final Long mMediaSetPickerId; public MediaInMediaSetSyncRequestParams(@NonNull Bundle extras) { Objects.requireNonNull(extras); - mAuthority = extras.getString("authority"); - mMediaSetPickerId = extras.getString("media_set_picker_id"); + mAuthority = extras.getString(KEY_PARENT_MEDIA_SET_AUTHORITY); + mMediaSetPickerId = extras.getLong(KEY_PARENT_MEDIA_SET_PICKER_ID); Objects.requireNonNull(mAuthority); - Objects.requireNonNull(mMediaSetPickerId); } public String getAuthority() { return mAuthority; } - public String getMediaSetPickerId() { + public Long getMediaSetPickerId() { return mMediaSetPickerId; } } diff --git a/src/com/android/providers/media/photopicker/v2/model/MediaSetsSyncRequestParams.java b/src/com/android/providers/media/photopicker/v2/model/MediaSetsSyncRequestParams.java index 821306bc3..029b034e1 100644 --- a/src/com/android/providers/media/photopicker/v2/model/MediaSetsSyncRequestParams.java +++ b/src/com/android/providers/media/photopicker/v2/model/MediaSetsSyncRequestParams.java @@ -27,6 +27,9 @@ import java.util.Objects; provider. */ public class MediaSetsSyncRequestParams { + public static final String KEY_PARENT_CATEGORY_AUTHORITY = "parent_category_authority"; + public static final String KEY_MIME_TYPES = "mime_types"; + public static final String KEY_PARENT_CATEGORY_ID = "parent_category_id"; private final String mAuthority; private final String mCategoryId; @@ -34,9 +37,9 @@ public class MediaSetsSyncRequestParams { public MediaSetsSyncRequestParams(@NonNull Bundle extras) { Objects.requireNonNull(extras); - mAuthority = extras.getString("authority"); - mMimeTypes = extras.getStringArray("mime_types"); - mCategoryId = extras.getString("category_id"); + mAuthority = extras.getString(KEY_PARENT_CATEGORY_AUTHORITY); + mMimeTypes = extras.getStringArray(KEY_MIME_TYPES); + mCategoryId = extras.getString(KEY_PARENT_CATEGORY_ID); } public String getAuthority() { diff --git a/src/com/android/providers/media/photopicker/v2/sqlite/MediaGroupCursorUtils.java b/src/com/android/providers/media/photopicker/v2/sqlite/MediaGroupCursorUtils.java index aca3a2ad6..7d5c22447 100644 --- a/src/com/android/providers/media/photopicker/v2/sqlite/MediaGroupCursorUtils.java +++ b/src/com/android/providers/media/photopicker/v2/sqlite/MediaGroupCursorUtils.java @@ -142,10 +142,12 @@ public class MediaGroupCursorUtils { /** * @param cursor Input * {@link com.android.providers.media.photopicker.v2.model.AlbumsCursorWrapper} + * @param index The index for the first album in the given albums cursor. + * The index value can be used to generate unique picker id for albums. * @return Cursor with the columns {@link PickerSQLConstants.MediaGroupResponseColumns}. */ @Nullable - public static Cursor getMediaGroupCursorForAlbums(@Nullable Cursor cursor) { + public static Cursor getMediaGroupCursorForAlbums(@Nullable Cursor cursor, long index) { if (cursor == null) { return null; } @@ -173,8 +175,9 @@ public class MediaGroupCursorUtils { final String albumId = cursor.getString(cursor.getColumnIndexOrThrow( PickerSQLConstants.AlbumResponse.ALBUM_ID.getColumnName())); - final String pickerId = cursor.getString(cursor.getColumnIndexOrThrow( - PickerSQLConstants.AlbumResponse.PICKER_ID.getColumnName())); + // Sets the picker id of the current album and increments the index for the + // next album. + final long pickerId = index++; final String displayName = cursor.getString(cursor.getColumnIndexOrThrow( PickerSQLConstants.AlbumResponse.ALBUM_NAME.getColumnName())); @@ -209,12 +212,16 @@ public class MediaGroupCursorUtils { /** * @param cursor Input * {@link CloudMediaProviderContract.MediaCategoryColumns} cursor. + * @param authority The authority of the category's CMP. + * @param index The index for the first category in the given categories cursor. + * The index value can be used to generate unique picker id for categories. * @return Cursor with the columns {@link PickerSQLConstants.MediaGroupResponseColumns}. */ @Nullable public static Cursor getMediaGroupCursorForCategories( @Nullable Cursor cursor, - @NonNull String authority) { + @NonNull String authority, + long index) { if (cursor == null) { return null; } @@ -296,7 +303,7 @@ public class MediaGroupCursorUtils { response.addRow(new Object[]{ MediaGroup.CATEGORY.name(), categoryId, - /* pickerId */ null, + index, displayName, authority, coverUri1, diff --git a/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsCloudSubQuery.java b/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsCloudSubQuery.java index d9c5a8e16..969394707 100644 --- a/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsCloudSubQuery.java +++ b/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsCloudSubQuery.java @@ -30,7 +30,7 @@ import java.util.Locale; * Picker DB. */ public class MediaInMediaSetsCloudSubQuery extends MediaInMediaSetsSubQuery { - public MediaInMediaSetsCloudSubQuery(Bundle queryArgs, String mediaSetPickerId) { + public MediaInMediaSetsCloudSubQuery(Bundle queryArgs, Long mediaSetPickerId) { super(queryArgs, mediaSetPickerId); } diff --git a/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsLocalSubQuery.java b/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsLocalSubQuery.java index d00e4306c..0e1df140f 100644 --- a/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsLocalSubQuery.java +++ b/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsLocalSubQuery.java @@ -31,7 +31,7 @@ import java.util.Locale; * Picker DB. */ public class MediaInMediaSetsLocalSubQuery extends MediaInMediaSetsSubQuery { - public MediaInMediaSetsLocalSubQuery(Bundle queryArgs, String mediaSetPickerId) { + public MediaInMediaSetsLocalSubQuery(Bundle queryArgs, Long mediaSetPickerId) { super(queryArgs, mediaSetPickerId); } diff --git a/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsQuery.java b/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsQuery.java index f0ba09f1a..09172721c 100644 --- a/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsQuery.java +++ b/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsQuery.java @@ -44,11 +44,8 @@ public class MediaInMediaSetsQuery { final MediaInMediaSetsCloudSubQuery mCloudMediaSubquery; - public MediaInMediaSetsQuery(Bundle queryArgs, @NonNull String mediaPickerSetId) { + public MediaInMediaSetsQuery(Bundle queryArgs, @NonNull Long mediaPickerSetId) { Objects.requireNonNull(mediaPickerSetId); - if (mediaPickerSetId.isEmpty()) { - throw new RuntimeException("MediaSet pickerId to query media cannot be null"); - } mIntentAction = queryArgs.getString("intent_action"); mProviders = new ArrayList<>( Objects.requireNonNull(queryArgs.getStringArrayList("providers"))); diff --git a/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsSubQuery.java b/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsSubQuery.java index 2e37e5255..ea536ee39 100644 --- a/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsSubQuery.java +++ b/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsSubQuery.java @@ -30,9 +30,9 @@ import java.util.Locale; * and media table in Picker DB. */ public abstract class MediaInMediaSetsSubQuery extends MediaQuery { - private final String mMediaSetPickerId; + private final Long mMediaSetPickerId; - public MediaInMediaSetsSubQuery(Bundle queryArgs, String mediaSetPickerId) { + public MediaInMediaSetsSubQuery(Bundle queryArgs, Long mediaSetPickerId) { super(queryArgs); mMediaSetPickerId = mediaSetPickerId; diff --git a/src/com/android/providers/media/photopicker/v2/sqlite/PickerSQLConstants.java b/src/com/android/providers/media/photopicker/v2/sqlite/PickerSQLConstants.java index 750d3851a..fc9945a29 100644 --- a/src/com/android/providers/media/photopicker/v2/sqlite/PickerSQLConstants.java +++ b/src/com/android/providers/media/photopicker/v2/sqlite/PickerSQLConstants.java @@ -42,6 +42,8 @@ import java.util.Objects; public class PickerSQLConstants { public static final int DEFAULT_SEARCH_SUGGESTIONS_LIMIT = 50; public static final int DEFAULT_SEARCH_HISTORY_SUGGESTIONS_LIMIT = 3; + public static String EXTRA_SEARCH_REQUEST_ID = "search_request_id"; + public static String EXTRA_SEARCH_PROVIDER_AUTHORITIES = "search_provider_authorities"; static final String COUNT_COLUMN = "Count"; /** @@ -351,13 +353,13 @@ public class PickerSQLConstants { /** Source provider's authority. */ AUTHORITY("authority"), /** Cover image Uri for the group. */ - UNWRAPPED_COVER_URI("cover_uri_1"), + UNWRAPPED_COVER_URI("unwrapped_cover_uri"), /** Additional cover image Uri for the category. */ - ADDITIONAL_UNWRAPPED_COVER_URI_1("cover_uri_2"), + ADDITIONAL_UNWRAPPED_COVER_URI_1("additional_cover_uri_1"), /** Additional cover image Uri for the category. */ - ADDITIONAL_UNWRAPPED_COVER_URI_2("cover_uri_3"), + ADDITIONAL_UNWRAPPED_COVER_URI_2("additional_cover_uri_2"), /** Additional cover image Uri for the category. */ - ADDITIONAL_UNWRAPPED_COVER_URI_3("cover_uri_4"), + ADDITIONAL_UNWRAPPED_COVER_URI_3("additional_cover_uri_3"), /** If the media group is category, this will be populated with the category type. */ CATEGORY_TYPE("category_type"), /** True, if the media category is leaf category which contains media sets, diff --git a/src/com/android/providers/media/scan/ModernMediaScanner.java b/src/com/android/providers/media/scan/ModernMediaScanner.java index 066fbd785..b33d21a96 100644 --- a/src/com/android/providers/media/scan/ModernMediaScanner.java +++ b/src/com/android/providers/media/scan/ModernMediaScanner.java @@ -475,17 +475,18 @@ public class ModernMediaScanner implements MediaScanner { private int mDeleteCount; /** - * Tracks hidden directory and hidden subdirectories in a directory tree. A positive count - * indicates that one or more of the current file's parents is a hidden directory. - */ - private int mHiddenDirCount; - /** * Indicates if the nomedia directory tree is dirty. When a nomedia directory is dirty, we * mark the top level nomedia as dirty. Hence if one of the sub directory in the nomedia * directory is dirty, we consider the whole top level nomedia directory tree as dirty. */ private boolean mIsDirectoryTreeDirty; + /** + * Tracks hidden directory and hidden subdirectories in a directory tree. + */ + private boolean mIsDirectoryTreeHidden = false; + private String mTopLevelHiddenDirectory; + Scan(File root, int reason) throws FileNotFoundException { Trace.beginSection("Scanner.ctor"); @@ -551,16 +552,20 @@ public class ModernMediaScanner implements MediaScanner { private void walkFileTree() { mSignal.throwIfCanceled(); - final Pair<Boolean, Boolean> isDirScannableAndHidden = - shouldScanPathAndIsPathHidden(mSingleFile ? mRoot.getParentFile() : mRoot); + + File dirPath = mSingleFile ? mRoot.getParentFile() : mRoot; + final Pair<Boolean, Boolean> isDirScannableAndHidden = shouldScanPathAndIsPathHidden( + dirPath); if (isDirScannableAndHidden.first) { // This directory is scannable. Trace.beginSection("Scanner.walkFileTree"); if (isDirScannableAndHidden.second) { // This directory is hidden - mHiddenDirCount++; + mIsDirectoryTreeHidden = true; + mTopLevelHiddenDirectory = dirPath.getAbsolutePath(); } + if (mSingleFile) { acquireDirectoryLock(mRoot.getParentFile().toPath().toString()); } @@ -842,8 +847,9 @@ public class ModernMediaScanner implements MediaScanner { // overlap and confuse each other acquireDirectoryLock(dir.toString()); - if (FileUtils.isDirectoryHidden(dir.toFile())) { - mHiddenDirCount++; + if (!mIsDirectoryTreeHidden && FileUtils.isDirectoryHidden(dir.toFile())) { + mIsDirectoryTreeHidden = true; + mTopLevelHiddenDirectory = dir.toString(); } // Scan this directory as a normal file so that "parent" database @@ -1016,7 +1022,7 @@ public class ModernMediaScanner implements MediaScanner { File file, String mimeType, int defaultMediaType) { if (mimeType != null) { return resolveMediaTypeFromFilePath( - file, mimeType, /*isHidden*/ mHiddenDirCount > 0); + file, mimeType, /*isHidden*/ mIsDirectoryTreeHidden); } return defaultMediaType; } @@ -1075,8 +1081,18 @@ public class ModernMediaScanner implements MediaScanner { // before releasing our lock below applyPending(); - if (FileUtils.isDirectoryHidden(dir.toFile())) { - mHiddenDirCount--; + boolean isDirHidden = FileUtils.isDirectoryHidden(dir.toFile()); + + if (isDirHidden && !mIsDirectoryTreeHidden) { + Log.w(TAG, "Hidden state of directory " + dir + " changed during active scan."); + } + + if (mTopLevelHiddenDirectory != null && dir.toString().equals( + mTopLevelHiddenDirectory)) { + // Post visit the top level hidden directory being tracked. Reset hidden status + // for directory tree. + mIsDirectoryTreeHidden = false; + mTopLevelHiddenDirectory = null; } // Now that we're finished scanning this directory, release lock to 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/src/com/android/providers/media/MediaProviderForFuseTest.java b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java index 216bcb9b1..ab3c4969f 100644 --- a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java +++ b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java @@ -207,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); @@ -256,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/photopicker/ItemsProviderTest.java b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java index f39da9c09..9e5317bd4 100644 --- a/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java +++ b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java @@ -1423,7 +1423,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/sync/MediaInMediaSetsSyncWorkerTest.java b/tests/src/com/android/providers/media/photopicker/sync/MediaInMediaSetsSyncWorkerTest.java index 509a3f6dd..6b256f555 100644 --- a/tests/src/com/android/providers/media/photopicker/sync/MediaInMediaSetsSyncWorkerTest.java +++ b/tests/src/com/android/providers/media/photopicker/sync/MediaInMediaSetsSyncWorkerTest.java @@ -195,9 +195,11 @@ public class MediaInMediaSetsSyncWorkerTest { assertEquals("Count of inserted media sets should be equal to the cursor size", /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); Bundle extras = new Bundle(); - extras.putString("authority", auth); - extras.putString("category_id", categoryId); - extras.putStringArray("mime_types", mimeTypes.toArray(new String[mimeTypes.size()])); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, auth); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArray( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + mimeTypes.toArray(new String[mimeTypes.size()])); MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( mDatabase, requestParams); @@ -314,9 +316,10 @@ public class MediaInMediaSetsSyncWorkerTest { assertEquals("Count of inserted media sets should be equal to the cursor size", /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); Bundle extras = new Bundle(); - extras.putString("authority", auth); - extras.putString("category_id", categoryId); - extras.putStringArray("mime_types", mimeTypes.toArray(new String[mimeTypes.size()])); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, auth); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArray(MediaSetsSyncRequestParams.KEY_MIME_TYPES, + mimeTypes.toArray(new String[mimeTypes.size()])); MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( mDatabase, requestParams); @@ -432,9 +435,10 @@ public class MediaInMediaSetsSyncWorkerTest { assertEquals("Count of inserted media sets should be equal to the cursor size", /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); Bundle extras = new Bundle(); - extras.putString("authority", auth); - extras.putString("category_id", categoryId); - extras.putStringArray("mime_types", mimeTypes.toArray(new String[mimeTypes.size()])); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, auth); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); + extras.putStringArray(MediaSetsSyncRequestParams.KEY_MIME_TYPES, + mimeTypes.toArray(new String[mimeTypes.size()])); MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( mDatabase, requestParams); 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 200242dfa..c9c972cb2 100644 --- a/tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java +++ b/tests/src/com/android/providers/media/photopicker/sync/PickerSyncManagerTest.java @@ -68,6 +68,7 @@ import com.android.providers.media.photopicker.v2.model.MediaSetsSyncRequestPara import com.google.common.util.concurrent.ListenableFuture; import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Captor; @@ -125,6 +126,7 @@ public class PickerSyncManagerTest { assertThat(workRequest.getWorkSpec().expedited).isFalse(); } + @Ignore("b/387570966") @Test public void testSchedulePeriodicSyncs() { setupPickerSyncManager(/* schedulePeriodicSyncs */ true); @@ -162,6 +164,7 @@ public class PickerSyncManagerTest { .isEqualTo(SYNC_LOCAL_AND_CLOUD); } + @Ignore("b/387570966") @Test public void testPeriodicWorkIsScheduledOnDeviceConfigChanges() { @@ -519,9 +522,10 @@ public class PickerSyncManagerTest { String categoryId = "id"; String[] mimeTypes = new String[] { "image/*" }; Bundle extras = new Bundle(); - extras.putString("authority", SearchProvider.AUTHORITY); - extras.putStringArray("mime_types", mimeTypes); - extras.putString("category_id", categoryId); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, + SearchProvider.AUTHORITY); + extras.putStringArray(MediaSetsSyncRequestParams.KEY_MIME_TYPES, mimeTypes); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); extras.putStringArrayList("providers", new ArrayList<>(List.of( PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY))); @@ -563,9 +567,10 @@ public class PickerSyncManagerTest { String categoryId = "id"; String[] mimeTypes = new String[] { "image/*" }; Bundle extras = new Bundle(); - extras.putString("authority", SearchProvider.AUTHORITY); - extras.putStringArray("mime_types", mimeTypes); - extras.putString("category_id", categoryId); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, + SearchProvider.AUTHORITY); + extras.putStringArray(MediaSetsSyncRequestParams.KEY_MIME_TYPES, mimeTypes); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, categoryId); extras.putStringArrayList("providers", new ArrayList<>(List.of( SearchProvider.AUTHORITY))); @@ -604,10 +609,12 @@ public class PickerSyncManagerTest { public void testMediaInMediaSetSyncLocalProvider() { setupPickerSyncManager(/*schedulePeriodicSyncs*/ false); - String mediaSetPickerId = "id"; + Long mediaSetPickerId = 1L; Bundle extras = new Bundle(); - extras.putString("authority", SearchProvider.AUTHORITY); - extras.putString("media_set_picker_id", mediaSetPickerId); + 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))); @@ -633,7 +640,7 @@ public class PickerSyncManagerTest { .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) .isEqualTo(SYNC_LOCAL_ONLY); assertThat(workRequest.getWorkSpec().input - .getString(SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID)) + .getLong(SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, -1)) .isEqualTo(mediaSetPickerId); assertThat(workRequest.getWorkSpec().input .getString(SYNC_WORKER_INPUT_AUTHORITY)) @@ -644,10 +651,14 @@ public class PickerSyncManagerTest { public void testMediaInMediaSetSyncCloudProvider() { setupPickerSyncManager(/*schedulePeriodicSyncs*/ false); - String mediaSetPickerId = "id"; + Long mediaSetPickerId = 1L; Bundle extras = new Bundle(); - extras.putString("authority", SearchProvider.AUTHORITY); - extras.putString("media_set_picker_id", mediaSetPickerId); + 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))); @@ -673,7 +684,7 @@ public class PickerSyncManagerTest { .getInt(SYNC_WORKER_INPUT_SYNC_SOURCE, -1)) .isEqualTo(SYNC_CLOUD_ONLY); assertThat(workRequest.getWorkSpec().input - .getString(SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID)) + .getLong(SYNC_WORKER_INPUT_MEDIA_SET_PICKER_ID, -1)) .isEqualTo(mediaSetPickerId); assertThat(workRequest.getWorkSpec().input .getString(SYNC_WORKER_INPUT_AUTHORITY)) 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 5799844b7..b08998029 100644 --- a/tests/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2Test.java +++ b/tests/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2Test.java @@ -117,6 +117,8 @@ 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; @@ -838,9 +840,13 @@ public class PickerDataLayerV2Test { SearchProvider.AUTHORITY, mimeTypes); Bundle extras = new Bundle(); - extras.putString("authority", SearchProvider.AUTHORITY); - extras.putStringArray("mime_types", new String[] { "image/*" }); - extras.putString("category_id", categoryId); + 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, categoryId); extras.putStringArrayList("providers", new ArrayList<>(List.of(SearchProvider.AUTHORITY))); try (Cursor mediaSets = PickerDataLayerV2.queryMediaSets(extras)) { @@ -1030,7 +1036,7 @@ public class PickerDataLayerV2Test { final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); - String mediaSetPickerId = "mediaSetPickerId"; + Long mediaSetPickerId = 1L; int cloudRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mFacade.getDatabase(), List.of( @@ -1057,8 +1063,12 @@ public class PickerDataLayerV2Test { extras.putStringArrayList("providers", new ArrayList<>(Arrays.asList(LOCAL_PROVIDER, CLOUD_PROVIDER))); extras.putString("intent_action", MediaStore.ACTION_PICK_IMAGES); - extras.putString("media_set_picker_id", mediaSetPickerId); - extras.putString("authority", LOCAL_PROVIDER); + 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)) { @@ -2691,9 +2701,13 @@ public class PickerDataLayerV2Test { doReturn(mMockFuture).when(mMockOperation).getResult(); Bundle extras = new Bundle(); - extras.putString("authority", SearchProvider.AUTHORITY); - extras.putStringArray("mime_types", new String[] { "image/*" }); - extras.putString("category_id", "id"); + 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); @@ -2714,8 +2728,10 @@ public class PickerDataLayerV2Test { doReturn(mMockFuture).when(mMockOperation).getResult(); Bundle extras = new Bundle(); - extras.putString("authority", SearchProvider.AUTHORITY); - extras.putString("media_set_picker_id", "id"); + 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); @@ -2760,6 +2776,11 @@ public class PickerDataLayerV2Test { .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") @@ -2768,20 +2789,29 @@ public class PickerDataLayerV2Test { 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 groupd is people and pets category + // 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") @@ -2790,21 +2820,24 @@ public class PickerDataLayerV2Test { 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 groupd is a cloud album + // 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 @@ -2812,6 +2845,11 @@ public class PickerDataLayerV2Test { 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); } } @@ -2974,7 +3012,7 @@ public class PickerDataLayerV2Test { } private ContentValues getContentValues( - String localId, String cloudId, String mediaSetPickerId) { + String localId, String cloudId, Long mediaSetPickerId) { ContentValues contentValues = new ContentValues(); contentValues.put( PickerSQLConstants.MediaInMediaSetsTableColumns.CLOUD_ID.getColumnName(), cloudId); 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 index b4eb2af3d..64db8598c 100644 --- a/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsDatabaseUtilTest.java +++ b/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaInMediaSetsDatabaseUtilTest.java @@ -108,7 +108,7 @@ public class MediaInMediaSetsDatabaseUtilTest { final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); - String mediaSetPickerId = "mediaSetPickerId"; + Long mediaSetPickerId = 1L; int cloudRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mDatabase, List.of( @@ -166,7 +166,7 @@ public class MediaInMediaSetsDatabaseUtilTest { final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); - String mediaSetPickerId = "mediaSetPickerId"; + Long mediaSetPickerId = 1L; final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mDatabase, List.of( @@ -220,8 +220,8 @@ public class MediaInMediaSetsDatabaseUtilTest { final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); - String mediaSetPickerId1 = "ms1"; - String mediaSetPickerId2 = "ms2"; + Long mediaSetPickerId1 = 1L; + Long mediaSetPickerId2 = 2L; final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mDatabase, List.of( @@ -273,7 +273,7 @@ public class MediaInMediaSetsDatabaseUtilTest { final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, dateTaken); assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); - String mediaSetPickerId = "mediaSetPickerId"; + Long mediaSetPickerId = 1L; final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mDatabase, List.of( @@ -343,7 +343,7 @@ public class MediaInMediaSetsDatabaseUtilTest { final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_3, LOCAL_ID_3, 0); assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); - String mediaSetPickerId = "mediaSetPickerId"; + Long mediaSetPickerId = 1L; final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mDatabase, List.of( @@ -402,7 +402,7 @@ public class MediaInMediaSetsDatabaseUtilTest { JPEG_IMAGE_MIME_TYPE, STANDARD_MIME_TYPE_EXTENSION, /* isFavorite */ false); assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); - String mediaSetPickerId = "mediaSetPickerId"; + Long mediaSetPickerId = 1L; final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mDatabase, List.of( @@ -463,7 +463,7 @@ public class MediaInMediaSetsDatabaseUtilTest { final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, 0); assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); - String mediaSetPickerId = "mediaSetPickerId"; + Long mediaSetPickerId = 1L; final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mDatabase, List.of( @@ -517,7 +517,7 @@ public class MediaInMediaSetsDatabaseUtilTest { final Cursor cursor4 = getLocalMediaCursor(LOCAL_ID_4, 0); assertAddMediaOperation(mFacade, LOCAL_PROVIDER, cursor4, 1); - String mediaSetPickerId = "mediaSetPickerId"; + Long mediaSetPickerId = 1L; final long cloudRowsInsertedCount = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mDatabase, List.of( @@ -582,7 +582,7 @@ public class MediaInMediaSetsDatabaseUtilTest { final Cursor cursor3 = getCloudMediaCursor(CLOUD_ID_2, LOCAL_ID_2, 0); assertAddMediaOperation(mFacade, CLOUD_PROVIDER, cursor3, 1); - String mediaSetPickerId = "mediaSetPickerId"; + Long mediaSetPickerId = 1L; int cloudRowsInserted = MediaInMediaSetsDatabaseUtil.cacheMediaOfMediaSet( mDatabase, List.of( @@ -608,7 +608,7 @@ public class MediaInMediaSetsDatabaseUtilTest { } private ContentValues getContentValues( - String localId, String cloudId, String mediaSetPickerId) { + String localId, String cloudId, Long mediaSetPickerId) { ContentValues contentValues = new ContentValues(); contentValues.put( PickerSQLConstants.MediaInMediaSetsTableColumns.CLOUD_ID.getColumnName(), cloudId); 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 index c9e2a8170..d74fc9591 100644 --- a/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaSetsDatabaseUtilsTest.java +++ b/tests/src/com/android/providers/media/photopicker/v2/sqlite/MediaSetsDatabaseUtilsTest.java @@ -141,9 +141,11 @@ public class MediaSetsDatabaseUtilsTest { .that(insertResult) .isAtLeast(/* expected min row id */ 0); Bundle extras = new Bundle(); - extras.putString("authority", mAuthority); - extras.putString("category_id", mCategoryId); - extras.putStringArray("mime_types", mimeTypes.toArray(new String[mimeTypes.size()])); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, mAuthority); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, mCategoryId); + extras.putStringArray( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + mimeTypes.toArray(new String[mimeTypes.size()])); MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); Cursor mediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( @@ -172,9 +174,11 @@ public class MediaSetsDatabaseUtilsTest { assertEquals("Count of inserted media sets should be equal to the cursor size", /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); Bundle extras = new Bundle(); - extras.putString("authority", mAuthority); - extras.putString("category_id", mCategoryId); - extras.putStringArray("mime_types", mimeTypes.toArray(new String[mimeTypes.size()])); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, mAuthority); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, mCategoryId); + extras.putStringArray( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + mimeTypes.toArray(new String[mimeTypes.size()])); MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( mDatabase, requestParams); @@ -208,9 +212,11 @@ public class MediaSetsDatabaseUtilsTest { assertEquals("Count of inserted media sets should be equal to the cursor size", /*expected*/ c.getCount(), /*actual*/ mediaSetsInserted); Bundle extras = new Bundle(); - extras.putString("authority", mAuthority); - extras.putString("category_id", mCategoryId); - extras.putStringArray("mime_types", mimeTypes.toArray(new String[mimeTypes.size()])); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_AUTHORITY, mAuthority); + extras.putString(MediaSetsSyncRequestParams.KEY_PARENT_CATEGORY_ID, mCategoryId); + extras.putStringArray( + MediaSetsSyncRequestParams.KEY_MIME_TYPES, + mimeTypes.toArray(new String[mimeTypes.size()])); MediaSetsSyncRequestParams requestParams = new MediaSetsSyncRequestParams(extras); Cursor fetchMediaSetCursor = MediaSetsDatabaseUtil.getMediaSetsForCategory( mDatabase, requestParams); |