Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 2dfd0e66 authored by Abi Ene's avatar Abi Ene Committed by Android (Google) Code Review
Browse files

Merge "Improve human-readability of app_function execute-app-function output" into main

parents 120433e3 3e5c1e19
Loading
Loading
Loading
Loading
+24 −4
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import static android.app.appfunctions.AppFunctionManager.ACCESS_FLAG_OTHER_GRAN
import static com.android.server.appfunctions.AppSearchDataJsonConverter.convertGenericDocumentsToJsonArray;
import static com.android.server.appfunctions.AppSearchDataJsonConverter.convertJsonToGenericDocument;
import static com.android.server.appfunctions.AppSearchDataJsonConverter.searchResultToJsonObject;
import static com.android.server.appfunctions.AppSearchDataYamlConverter.convertGenericDocumentsToYaml;

import android.annotation.NonNull;
import android.app.ActivityManager;
@@ -86,7 +87,7 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
        pw.println(
                "  execute-app-function --package <PACKAGE_NAME> --function <FUNCTION_ID> "
                        + "--parameters <PARAMETERS_JSON> [--user <USER_ID>]"
                        + "[--timeout-duration <SECONDS>]");
                        + "[--timeout-duration <SECONDS>] [--brief-yaml]");
        pw.println(
                "    Executes an app function for the given package with the provided parameters "
                        + " and returns the result as a JSON string");
@@ -102,6 +103,8 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
                "    --timeout-duration <SECONDS> (optional): The timeout for the function "
                        + "execution in seconds. Defaults to " + DEFAULT_EXECUTE_TIMEOUT_SECONDS
                        + " seconds.");
        pw.println(
                "    --brief-yaml (optional): Prints a concise yaml output.");
        pw.println();
        pw.println(
                "  set-enabled --package <PACKAGE_NAME> --function <FUNCTION_ID> "
@@ -354,6 +357,7 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
        String parametersJson = null;
        int userId = ActivityManager.getCurrentUser();
        long timeoutDurationSeconds = DEFAULT_EXECUTE_TIMEOUT_SECONDS;
        boolean briefYaml = false;
        String opt;

        while ((opt = getNextOption()) != null) {
@@ -382,6 +386,9 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
                                + ". Using default of " + DEFAULT_EXECUTE_TIMEOUT_SECONDS + "s.");
                    }
                    break;
                case "--brief-yaml":
                    briefYaml = true;
                    break;
                default:
                    pw.println("Unknown option: " + opt);
                    return -1;
@@ -414,6 +421,7 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {

        CountDownLatch countDownLatch = new CountDownLatch(1);
        final AtomicInteger resultCode = new AtomicInteger(0);
        final boolean finalBriefYaml = briefYaml;
        IExecuteAppFunctionCallback callback =
                new IExecuteAppFunctionCallback.Stub() {

@@ -431,15 +439,27 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
                            }
                            // HACK: GenericDocument doesn't tell whether a property is singular
                            // or repeated. We always assume the return is an array here.
                            if (finalBriefYaml) {
                                String functionReturnYaml =
                                        convertGenericDocumentsToYaml(
                                            functionReturn,
                                            /*keepEmptyValues=*/ false,
                                            /*keepNullValues=*/ false,
                                            /*keepGenericDocumentProperties=*/ false
                                        );
                                pw.println(functionReturnYaml);
                            } else {
                                JSONArray functionReturnJson =
                                    convertGenericDocumentsToJsonArray(functionReturn);
                                pw.println(functionReturnJson.toString(/*indentSpace=*/ 2));
                            }
                        } catch (JSONException e) {
                            pw.println("Failed to convert the function response to JSON.");
                            resultCode.set(-1);
                        }
                        } finally {
                            countDownLatch.countDown();
                        }
                    }

                    @Override
                    public void onError(AppFunctionException e) {
+268 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.server.appfunctions;

import android.app.appsearch.GenericDocument;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Converts AppSearch {@link GenericDocument} objects into human-readable YAML strings.
 * This class uses a minimal, dependency-free YAML generator to avoid binary size increases.
 */
public class AppSearchDataYamlConverter {

    private AppSearchDataYamlConverter() {}

    /**
     * Converts an array of {@link GenericDocument} objects into a YAML string. This method provides
     * options to control the output.
     *
     * @param documents The array of {@link GenericDocument} to convert.
     * @param keepEmptyValues If false, properties with empty values (empty strings, empty array)
     * will be excluded.
     * @param keepNullValues If false, properties with null values will be excluded.
     * @param keepGenericDocumentProperties If false, document metadata (id, namespace, etc.) will
     *     not be included in the output.
     * @return A YAML string representing a list of documents.
     */
    public static String convertGenericDocumentsToYaml(
            GenericDocument[] documents,
            boolean keepEmptyValues,
            boolean keepNullValues,
            boolean keepGenericDocumentProperties) {
        List<Map<String, Object>> list = new ArrayList<>();
        for (GenericDocument doc : documents) {
            list.add(
                    genericDocumentToMap(
                        doc,
                        keepEmptyValues,
                        keepNullValues,
                        keepGenericDocumentProperties));
        }
        return MinimalYamlGenerator.dump(list);
    }

    /**
     * Recursively converts a {@link GenericDocument} into a {@link Map}, filtering based on flags.
     *
     * @return A {@link Map} representing the document's properties.
     */
    private static Map<String, Object> genericDocumentToMap(
            GenericDocument doc,
            boolean keepEmptyValues,
            boolean keepNullValues,
            boolean keepGenericDocumentProperties) {
        Map<String, Object> map = new LinkedHashMap<>();

        if (keepGenericDocumentProperties) {
            map.put("id", doc.getId());
            map.put("namespace", doc.getNamespace());
            map.put("schemaType", doc.getSchemaType());
            map.put("creationTimestampMillis", doc.getCreationTimestampMillis());
            map.put("score", doc.getScore());
        }

        Set<String> propertyNames = doc.getPropertyNames();

        for (String propName : propertyNames) {
            Object propValue = doc.getProperty(propName);

            if (!keepNullValues && propValue == null) {
                continue;
            }

            if (propValue == null) {
                map.put(propName, null);
                continue;
            }

            // HACK: GenericDocument doesn't tell whether a property is singular or
            // repeated. Here, we always convert a property into an array.
            if (propValue instanceof GenericDocument[]) {
                List<Map<String, Object>> list = new ArrayList<>();
                for (GenericDocument nestedDoc : (GenericDocument[]) propValue) {
                    list.add(
                            genericDocumentToMap(
                                nestedDoc,
                                keepEmptyValues,
                                keepNullValues,
                                keepGenericDocumentProperties));
                }

                if (!keepEmptyValues && list.isEmpty()) {
                    continue;
                }

                if (list.size() == 1) {
                    map.put(propName, list.get(0));
                } else {
                    map.put(propName, list);
                }
            } else if (propValue.getClass().isArray()) {
                int length = Array.getLength(propValue);

                List<Object> list = new ArrayList<>();
                for (int i = 0; i < length; i++) {
                    Object singleValue = Array.get(propValue, i);

                    if (!keepEmptyValues && isEmptyValue(singleValue)) {
                        continue;
                    }

                    list.add(singleValue);
                }

                if (!keepEmptyValues && list.isEmpty()) {
                    continue;
                }

                if (list.size() == 1) {
                    map.put(propName, list.get(0));
                } else {
                    map.put(propName, list);
                }
            }
        }

        return map;
    }

    /**
     * Checks if a given value is an empty value (empty string, or empty array).
     *
     * @param value The value to check.
     * @return true if the value is empty, false otherwise.
     */
    private static boolean isEmptyValue(Object value) {
        if (value == null) {
            return false;
        }

        if (value instanceof String && ((String) value).isEmpty()) {
            return true;
        }

        if (value.getClass().isArray() && Array.getLength(value) == 0) {
            return true;
        }

        return false;
    }

    /**
     * A minimal YAML generator to avoid a heavy library dependency. This supports Maps, Lists, and
     * primitive types, producing a readable YAML string.
     */
    private static class MinimalYamlGenerator {
        private static final String INDENT = "  ";

        public static String dump(Object data) {
            StringBuilder sb = new StringBuilder();
            dumpObject(data, sb, 0, true);
            return sb.toString();
        }

        private static void dumpObject(
                Object data,
                StringBuilder sb,
                int indentLevel,
                boolean indentFirst) {
            if (data instanceof Map) {
                dumpMap((Map<?, ?>) data, sb, indentLevel, indentFirst);
            } else if (data instanceof List) {
                dumpList((List<?>) data, sb, indentLevel);
            } else {
                sb.append(formatPrimitive(data));
            }
        }

        private static void dumpMap(
                Map<?, ?> map,
                StringBuilder sb,
                int indentLevel,
                boolean indentFirst) {
            String indent = INDENT.repeat(indentLevel);
            boolean isFirst = true;
            for (Map.Entry<?, ?> entry : map.entrySet()) {
                if (isFirst) {
                    if (indentFirst) {
                        sb.append(indent);
                    }
                } else {
                    sb.append("\n").append(indent);
                }

                sb.append(formatPrimitive(entry.getKey())).append(":");

                Object value = entry.getValue();

                if (isComplex(value)) {
                    sb.append("\n");
                    dumpObject(value, sb, indentLevel + 1, true);
                } else {
                    sb.append(" ");
                    dumpObject(value, sb, 0, true);
                }

                isFirst = false;
            }
        }

        private static void dumpList(List<?> list, StringBuilder sb, int indentLevel) {
            String indent = INDENT.repeat(indentLevel);
            boolean isFirst = true;
            for (Object item : list) {
                if (!isFirst) {
                    sb.append("\n");

                }
                sb.append(indent).append("- ");
                if (isComplex(item)) {
                    dumpObject(item, sb, indentLevel + 1, false);
                } else {
                    dumpObject(item, sb, 0, false);
                }
                isFirst = false;
            }
        }

        private static boolean isComplex(Object obj) {
            return obj instanceof Map || obj instanceof List;
        }

        private static String formatPrimitive(Object primitive) {
            if (primitive == null) {
                return "null";
            }

            if (primitive instanceof String) {
                String str = (String) primitive;
                // Basic quoting for strings containing YAML special characters.
                if (str.contains(": ") || str.contains("#") || str.isEmpty()) {
                    return "'" + str.replace("'", "''") + "'";
                }
            }

            return primitive.toString();
        }
    }
}
+244 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.server.appfunctions

import android.app.appsearch.GenericDocument
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class AppSearchDataYamlConverterTest {

    @Test
    fun convertGenericDocumentsToYaml_withScalarTypes_succeeds() {
        val doc = GenericDocument.Builder<GenericDocument.Builder<*>>("ns1", "doc1", "TestSchema")
          .setPropertyString("stringProp", "hello world")
          .setPropertyLong("longProp", 123L)
          .setPropertyDouble("doubleProp", 45.67)
          .setPropertyBoolean("boolProp", true)
          .setScore(10)
          .setCreationTimestampMillis(1000L)
          .build()

        val yaml = AppSearchDataYamlConverter.convertGenericDocumentsToYaml(
          arrayOf(doc),
          /* keepEmptyValues= */ true,
          /* keepNullValues= */ true,
          /* keepGenericDocumentProperties= */ true
        )

        val expectedYaml = """
                - id: doc1
                  namespace: ns1
                  schemaType: TestSchema
                  creationTimestampMillis: 1000
                  score: 10
                  longProp: 123
                  stringProp: hello world
                  doubleProp: 45.67
                  boolProp: true
            """.trimIndent()

        assertThat(yaml.trim()).isEqualTo(expectedYaml)
    }

    @Test
    fun convertGenericDocumentsToYaml_withoutDefaultValues_filtersProperties() {
        val doc = GenericDocument.Builder<GenericDocument.Builder<*>>("ns2", "doc2", "FilterSchema")
          .setPropertyString("emptyString", "")
          .setPropertyString("realString", "value")
          .setPropertyLong("zeroLong", 0L)
          .setPropertyBoolean("falseBoolean", false)
          .build()

        val yaml = AppSearchDataYamlConverter.convertGenericDocumentsToYaml(
          arrayOf(doc),
          /* keepEmptyValues= */ false,
          /* keepNullValues= */ false,
          /* keepGenericDocumentProperties= */ false
        )

        val expectedYaml = """
              - falseBoolean: false
                realString: value
                zeroLong: 0
        """.trimIndent()
        assertThat(yaml.trim()).isEqualTo(expectedYaml)
    }

    @Test
    fun convertGenericDocumentsToYaml_withoutDocProperties_filtersProperties() {
        val doc = GenericDocument.Builder<GenericDocument.Builder<*>>("ns3", "doc3", "NoDocProps")
          .setPropertyString("prop", "value")
          .build()

        val yaml = AppSearchDataYamlConverter.convertGenericDocumentsToYaml(
          arrayOf(doc),
          /* keepEmptyValues= */ true,
          /* keepNullValues= */ true,
          /* keepGenericDocumentProperties= */ false
        )

        val expectedYaml = "- prop: value"
        assertThat(yaml.trim()).isEqualTo(expectedYaml)
    }

    @Test
    fun convertGenericDocumentsToYaml_withArrayTypes_succeeds() {
        val doc = GenericDocument.Builder<GenericDocument.Builder<*>>("ns1", "doc4", "ArraySchema")
          .setPropertyString("stringArr", "a", "b", "c")
          .setPropertyLong("longArr", 1L, 2L)
          .build()

        val yaml = AppSearchDataYamlConverter.convertGenericDocumentsToYaml(
          arrayOf(doc),
          /* keepEmptyValues= */ true,
          /* keepNullValues= */ true,
          /* keepGenericDocumentProperties= */ false
        )

        val expectedYaml = """
                - stringArr:
                    - a
                    - b
                    - c
                  longArr:
                    - 1
                    - 2
            """.trimIndent()
        assertThat(yaml.trim()).isEqualTo(expectedYaml)
    }

    @Test
    fun convertGenericDocumentsToYaml_withNestedDocument_succeeds() {
        val nestedDoc = GenericDocument.Builder<GenericDocument.Builder<*>>("nestedNs", "nestedId", "Nested")
          .setPropertyString("nestedProp", "I am nested")
          .build()

        val mainDoc = GenericDocument.Builder<GenericDocument.Builder<*>>("mainNs", "mainId", "Main")
          .setPropertyDocument("nestedDoc", nestedDoc)
          .build()

        val yaml = AppSearchDataYamlConverter.convertGenericDocumentsToYaml(
          arrayOf(mainDoc),
          /* keepEmptyValues= */ true,
          /* keepNullValues= */ true,
          /* keepGenericDocumentProperties= */ false
        )

        val expectedYaml = """
                - nestedDoc:
                    nestedProp: I am nested
            """.trimIndent()
        assertThat(yaml.trim()).isEqualTo(expectedYaml)
    }

    @Test
    fun convertGenericDocumentsToYaml_withArrayOfNestedDocuments_succeeds() {
        val nestedDoc1 = GenericDocument.Builder<GenericDocument.Builder<*>>("ns", "n1", "Nested")
          .setPropertyString("prop", "first")
          .setCreationTimestampMillis(0L)
          .build()
        val nestedDoc2 = GenericDocument.Builder<GenericDocument.Builder<*>>("ns", "n2", "Nested")
          .setPropertyString("prop", "second")
          .setCreationTimestampMillis(1L)
          .build()

        val mainDoc = GenericDocument.Builder<GenericDocument.Builder<*>>("ns", "main", "Main")
          .setPropertyDocument("docArray", nestedDoc1, nestedDoc2)
          .setCreationTimestampMillis(1000L)
          .build()

        val yaml = AppSearchDataYamlConverter.convertGenericDocumentsToYaml(
          arrayOf(mainDoc),
          /* keepEmptyValues= */ true,
          /* keepNullValues= */ true,
          /* keepGenericDocumentProperties= */ true
        )

      val expectedYaml = """
            - id: main
              namespace: ns
              schemaType: Main
              creationTimestampMillis: 1000
              score: 0
              docArray:
                - id: n1
                  namespace: ns
                  schemaType: Nested
                  creationTimestampMillis: 0
                  score: 0
                  prop: first
                - id: n2
                  namespace: ns
                  schemaType: Nested
                  creationTimestampMillis: 1
                  score: 0
                  prop: second
        """.trimIndent()
        assertThat(yaml.trim()).isEqualTo(expectedYaml)
    }

    @Test
    fun convertGenericDocumentsToYaml_withMultipleDocuments_succeeds() {
        val doc1 = GenericDocument.Builder<GenericDocument.Builder<*>>("ns", "id1", "MySchema")
          .setPropertyString("prop", "val1")
          .setCreationTimestampMillis(0L)
          .build()
        val doc2 = GenericDocument.Builder<GenericDocument.Builder<*>>("ns", "id2", "MySchema")
          .setPropertyString("prop", "val2")
          .setCreationTimestampMillis(1L)
          .build()

        val yaml = AppSearchDataYamlConverter.convertGenericDocumentsToYaml(
          arrayOf(doc1, doc2),
          /* keepEmptyValues= */ false,
          /* keepNullValues= */ false,
          /* keepGenericDocumentProperties= */ true
        )

        val expectedYaml = """
                - id: id1
                  namespace: ns
                  schemaType: MySchema
                  creationTimestampMillis: 0
                  score: 0
                  prop: val1
                - id: id2
                  namespace: ns
                  schemaType: MySchema
                  creationTimestampMillis: 1
                  score: 0
                  prop: val2
            """.trimIndent()

        assertThat(yaml.trim()).isEqualTo(expectedYaml)
    }

    @Test
    fun convertGenericDocumentsToYaml_withEmptyArray_returnsEmptyList() {
        val yaml = AppSearchDataYamlConverter.convertGenericDocumentsToYaml(
          arrayOf(),
          /* keepEmptyValues= */ true,
          /* keepNullValues= */ true,
          /* keepGenericDocumentProperties= */ true
        )

        assertThat(yaml.trim()).isEqualTo("")
    }
}