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

Commit 60fc65e4 authored by Tony Mak's avatar Tony Mak
Browse files

Add shell command to list app functions in JSON

Also, execute-app-functions now output JSON

See https://bit.googleplex.com/#/tonymak/6386020876550144 for example output.

dumpsys app_function is for human to parse while this new command is for
program to process.

Bug: 427146112
Test: Added test and run the commands
FLAG: EXEMPT shell command only

Change-Id: I041fca7db0339021a7cece45a4700b383d42e9c6
parent 568bfcd1
Loading
Loading
Loading
Loading
+17 −8
Original line number Diff line number Diff line
@@ -46,6 +46,7 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;

public final class AppFunctionDumpHelper {
    private static final String TAG = AppFunctionDumpHelper.class.getSimpleName();
@@ -89,20 +90,19 @@ public final class AppFunctionDumpHelper {
        return false;
    }

    private static void dumpAppFunctionsStateForUser(
            @NonNull Context context, @NonNull IndentingPrintWriter pw, boolean isVerbose) {
    /** Returns a map where the key is the package name and the value is its appfunction state. */
    static Map<String, List<SearchResult>> queryAppFunctionsStateForUser(
            @NonNull Context context, boolean isVerbose)
            throws ExecutionException, InterruptedException {
        AppSearchManager appSearchManager = context.getSystemService(AppSearchManager.class);
        if (appSearchManager == null) {
            pw.println("Couldn't retrieve AppSearchManager.");
            return;
            throw new IllegalStateException("Couldn't retrieve AppSearchManager.");
        }

        Map<String, List<SearchResult>> packageSearchResults = new ArrayMap<>();

        try (FutureGlobalSearchSession searchSession =
                new FutureGlobalSearchSession(appSearchManager, Runnable::run)) {
            pw.println();

            try (FutureSearchResults futureSearchResults =
                    searchSession
                            .search("", buildAppFunctionMetadataSearchSpec(isVerbose))
@@ -122,10 +122,17 @@ public final class AppFunctionDumpHelper {
                                .add(searchResult);
                    }
                } while (!searchResultsList.isEmpty());

                dumpSearchResults(pw, packageSearchResults);
            }
        }
        return packageSearchResults;
    }

    private static void dumpAppFunctionsStateForUser(
            @NonNull Context context, @NonNull IndentingPrintWriter pw, boolean isVerbose) {
        try {
            Map<String, List<SearchResult>> packageSearchResults =
                    queryAppFunctionsStateForUser(context, isVerbose);
            dumpSearchResults(pw, packageSearchResults);
        } catch (Exception e) {
            pw.println("Failed to dump AppFunction state: " + e);
        }
@@ -134,10 +141,12 @@ public final class AppFunctionDumpHelper {
    private static void dumpSearchResults(
            IndentingPrintWriter pw, Map<String, List<SearchResult>> packageSearchResults) {
        for (Map.Entry<String, List<SearchResult>> entry : packageSearchResults.entrySet()) {
            pw.println();
            pw.println("AppFunctionDocument(s) for package: " + entry.getKey());

            pw.increaseIndent();
            for (SearchResult result : entry.getValue()) {
                pw.println();
                if (result.getGenericDocument()
                        .getSchemaType()
                        .startsWith(AppFunctionStaticMetadataHelper.STATIC_SCHEMA_TYPE)) {
+1 −1
Original line number Diff line number Diff line
@@ -225,7 +225,7 @@ public class AppFunctionManagerServiceImpl extends IAppFunctionManager.Stub {
            @NonNull String[] args,
            ShellCallback callback,
            @NonNull ResultReceiver resultReceiver) {
        new AppFunctionManagerServiceShellCommand(this)
        new AppFunctionManagerServiceShellCommand(mContext, this)
                .exec(this, in, out, err, args, callback, resultReceiver);
    }

+91 −96
Original line number Diff line number Diff line
@@ -20,6 +20,10 @@ import static android.app.appfunctions.AppFunctionManager.ACCESS_FLAG_MASK_OTHER
import static android.app.appfunctions.AppFunctionManager.ACCESS_FLAG_OTHER_DENIED;
import static android.app.appfunctions.AppFunctionManager.ACCESS_FLAG_OTHER_GRANTED;

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 android.annotation.NonNull;
import android.app.ActivityManager;
import android.app.appfunctions.AppFunctionException;
@@ -31,6 +35,8 @@ import android.app.appfunctions.IAppFunctionEnabledCallback;
import android.app.appfunctions.IAppFunctionManager;
import android.app.appfunctions.IExecuteAppFunctionCallback;
import android.app.appsearch.GenericDocument;
import android.app.appsearch.SearchResult;
import android.content.Context;
import android.os.Binder;
import android.os.ICancellationSignal;
import android.os.Process;
@@ -44,18 +50,21 @@ import org.json.JSONException;
import org.json.JSONObject;

import java.io.PrintWriter;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/** Shell command implementation for the {@link AppFunctionManagerService}. */
public class AppFunctionManagerServiceShellCommand extends ShellCommand {
    @NonNull private final Context mContext;
    @NonNull private final IAppFunctionManager mService;

    @NonNull
    private final IAppFunctionManager mService;

    AppFunctionManagerServiceShellCommand(@NonNull IAppFunctionManager service) {
    AppFunctionManagerServiceShellCommand(
            @NonNull Context context, @NonNull IAppFunctionManager service) {
        mContext = Objects.requireNonNull(context);
        mService = Objects.requireNonNull(service);
    }

@@ -66,11 +75,18 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
        pw.println("  help");
        pw.println("    Prints this help text.");
        pw.println();
        pw.println("  list-app-functions [--user <USER_ID>]");
        pw.println("    Lists all app functions for a specified user in JSON.");
        pw.println(
                "    --user <USER_ID> (optional): The user ID to list functions for. "
                        + "Defaults to the current user.");
        pw.println();
        pw.println(
                "  execute-app-function --package <PACKAGE_NAME> --function <FUNCTION_ID> "
                        + "--parameters <PARAMETERS_JSON> [--user <USER_ID>]");
        pw.println(
                "    Executes an app function for the given package with the provided parameters.");
                "    Executes an app function for the given package with the provided parameters "
                        + " and returns the result as a JSON string");
        pw.println("    --package <PACKAGE_NAME>: The target package name.");
        pw.println("    --function <FUNCTION_ID>: The ID of the app function to execute.");
        pw.println(
@@ -133,6 +149,8 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {

        try {
            switch (cmd) {
                case "list-app-functions":
                    return runListAppFunctions();
                case "execute-app-function":
                    return runExecuteAppFunction();
                case "set-enabled":
@@ -150,6 +168,49 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
        return -1;
    }

    private int runListAppFunctions() throws Exception {
        final PrintWriter pw = getOutPrintWriter();
        int userId = ActivityManager.getCurrentUser();
        String opt;

        while ((opt = getNextOption()) != null) {
            switch (opt) {
                case "--user":
                    try {
                        userId = UserHandle.parseUserArg(getNextArgRequired());
                    } catch (NumberFormatException e) {
                        pw.println("Invalid user ID: " + getNextArg() + ". Using current user.");
                    }
                    break;
                default:
                    pw.println("Unknown option: " + opt);
                    return -1;
            }
        }

        Context context = mContext.createContextAsUser(UserHandle.of(userId), /* flags= */ 0);
        long token = Binder.clearCallingIdentity();
        try {
            Map<String, List<SearchResult>> perPackageSearchResult =
                    AppFunctionDumpHelper.queryAppFunctionsStateForUser(
                            context, /* isVerbose= */ true);
            JSONObject jsonObject = new JSONObject();
            for (Map.Entry<String, List<SearchResult>> entry : perPackageSearchResult.entrySet()) {
                JSONArray searchResults = new JSONArray();
                for (SearchResult result : entry.getValue()) {
                    searchResults.put(searchResultToJsonObject(result));
                }
                jsonObject.put(entry.getKey(), searchResults);
            }
            pw.println(jsonObject.toString(/* indentSpaces =*/ 2));
        } finally {
            Binder.restoreCallingIdentity(token);
        }
        pw.flush();

        return 0;
    }

    private int runSetAppFunctionEnabled() throws Exception {
        final PrintWriter pw = getOutPrintWriter();
        String packageName = null;
@@ -284,7 +345,7 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
            return -1;
        }

        GenericDocument parameters = parseJsonToGenericDocument(parametersJson);
        GenericDocument parameters = convertJsonToGenericDocument(parametersJson);

        ExecuteAppFunctionAidlRequest request =
                new ExecuteAppFunctionAidlRequest(
@@ -296,15 +357,31 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
                        SystemClock.elapsedRealtime());

        CountDownLatch countDownLatch = new CountDownLatch(1);

        final AtomicInteger resultCode = new AtomicInteger(0);
        IExecuteAppFunctionCallback callback =
                new IExecuteAppFunctionCallback.Stub() {

                    @Override
                    public void onSuccess(ExecuteAppFunctionResponse response) {
                        pw.println("App function executed successfully.");
                        pw.println("Function return:");
                        pw.println(response.getResultDocument().toString());
                        try {
                            GenericDocument[] functionReturn =
                                    response.getResultDocument()
                                            .getPropertyDocumentArray(
                                                    ExecuteAppFunctionResponse
                                                            .PROPERTY_RETURN_VALUE);
                            if (functionReturn == null || functionReturn.length == 0) {
                                pw.println(new JSONObject());
                                return;
                            }
                            // HACK: GenericDocument doesn't tell whether a property is singular
                            // or repeated. We always assume the return is an array here.
                            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);
                        }
                        countDownLatch.countDown();
                    }

@@ -312,6 +389,7 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
                    public void onError(AppFunctionException e) {
                        Log.d(TAG, "onError: ", e);
                        pw.println("Error executing app function: " + e.getErrorCode() + " - " + e);
                        resultCode.set(-1);
                        countDownLatch.countDown();
                    }
                };
@@ -322,94 +400,11 @@ public class AppFunctionManagerServiceShellCommand extends ShellCommand {
        if (!returned) {
            pw.println("Timed out");
            cancellationSignal.cancel();
            resultCode.set(-1);
        }
        pw.flush();

        return 0;
    }

    /**
     * Converts a JSON string to a {@link GenericDocument}.
     *
     * <p>This method parses the provided JSON string and creates a {@link GenericDocument}
     * representation. It extracts the 'id', 'namespace', and 'schemaType' fields from the top-level
     * JSON object to initialize the {@code GenericDocument}. It then iterates through the remaining
     * keys in the JSON object and adds them as properties to the {@code GenericDocument}.
     *
     * <p>Example Input:
     *
     * <pre>{@code
     * {"createNoteParams":{"title":"My title"}}
     * }</pre>
     */
    private static GenericDocument parseJsonToGenericDocument(String jsonString)
            throws JSONException {
        JSONObject json = new JSONObject(jsonString);

        String id = json.optString("id", "");
        String namespace = json.optString("namespace", "");
        String schemaType = json.optString("schemaType", "");

        GenericDocument.Builder builder = new GenericDocument.Builder(id, namespace, schemaType);

        Iterator<String> keys = json.keys();
        while (keys.hasNext()) {
            String key = keys.next();
            Object value = json.get(key);

            if (value instanceof String) {
                builder.setPropertyString(key, (String) value);
            } else if (value instanceof Integer || value instanceof Long) {
                builder.setPropertyLong(key, ((Number) value).longValue());
            } else if (value instanceof Double || value instanceof Float) {
                builder.setPropertyDouble(key, ((Number) value).doubleValue());
            } else if (value instanceof Boolean) {
                builder.setPropertyBoolean(key, (Boolean) value);
            } else if (value instanceof JSONObject) {
                GenericDocument nestedDocument = parseJsonToGenericDocument(value.toString());
                builder.setPropertyDocument(key, nestedDocument);
            } else if (value instanceof JSONArray) {
                JSONArray array = (JSONArray) value;
                if (array.length() == 0) {
                    continue;
                }

                Object first = array.get(0);
                if (first instanceof String) {
                    String[] arr = new String[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        arr[i] = array.optString(i, null);
                    }
                    builder.setPropertyString(key, arr);
                } else if (first instanceof Integer || first instanceof Long) {
                    long[] arr = new long[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        arr[i] = array.getLong(i);
                    }
                    builder.setPropertyLong(key, arr);
                } else if (first instanceof Double || first instanceof Float) {
                    double[] arr = new double[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        arr[i] = array.getDouble(i);
                    }
                    builder.setPropertyDouble(key, arr);
                } else if (first instanceof Boolean) {
                    boolean[] arr = new boolean[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        arr[i] = array.getBoolean(i);
                    }
                    builder.setPropertyBoolean(key, arr);
                } else if (first instanceof JSONObject) {
                    GenericDocument[] documentArray = new GenericDocument[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        documentArray[i] =
                                parseJsonToGenericDocument(array.getJSONObject(i).toString());
                    }
                    builder.setPropertyDocument(key, documentArray);
                }
            }
        }
        return builder.build();
        return resultCode.get();
    }

    private int runGrantAppFunctionAccess() throws Exception {
+207 −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 android.app.appsearch.SearchResult;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.lang.reflect.Array;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

public class AppSearchDataJsonConverter {

    private AppSearchDataJsonConverter() {}

    /**
     * Converts a JSON string to a {@link GenericDocument}.
     *
     * <p>This method parses the provided JSON string and creates a {@link GenericDocument}
     * representation. It extracts the 'id', 'namespace', and 'schemaType' fields from the top-level
     * JSON object to initialize the {@code GenericDocument}. It then iterates through the remaining
     * keys in the JSON object and adds them as properties to the {@code GenericDocument}.
     *
     * <p>Example Input:
     *
     * <pre>{@code
     * {"createNoteParams":{"title":"My title"}}
     * }</pre>
     */
    public static GenericDocument convertJsonToGenericDocument(String jsonString)
            throws JSONException {
        JSONObject json = new JSONObject(jsonString);

        String id = json.optString("id", "");
        String namespace = json.optString("namespace", "");
        String schemaType = json.optString("schemaType", "");

        GenericDocument.Builder builder = new GenericDocument.Builder(namespace, id, schemaType);

        Iterator<String> keys = json.keys();
        while (keys.hasNext()) {
            String key = keys.next();
            Object value = json.get(key);

            if (value instanceof String) {
                builder.setPropertyString(key, (String) value);
            } else if (value instanceof Integer || value instanceof Long) {
                builder.setPropertyLong(key, ((Number) value).longValue());
            } else if (value instanceof Double || value instanceof Float) {
                builder.setPropertyDouble(key, ((Number) value).doubleValue());
            } else if (value instanceof Boolean) {
                builder.setPropertyBoolean(key, (Boolean) value);
            } else if (value instanceof JSONObject) {
                GenericDocument nestedDocument = convertJsonToGenericDocument(value.toString());
                builder.setPropertyDocument(key, nestedDocument);
            } else if (value instanceof JSONArray) {
                JSONArray array = (JSONArray) value;
                if (array.length() == 0) {
                    continue;
                }

                Object first = array.get(0);
                if (first instanceof String) {
                    String[] arr = new String[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        arr[i] = array.optString(i, null);
                    }
                    builder.setPropertyString(key, arr);
                } else if (first instanceof Integer || first instanceof Long) {
                    long[] arr = new long[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        arr[i] = array.getLong(i);
                    }
                    builder.setPropertyLong(key, arr);
                } else if (first instanceof Double || first instanceof Float) {
                    double[] arr = new double[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        arr[i] = array.getDouble(i);
                    }
                    builder.setPropertyDouble(key, arr);
                } else if (first instanceof Boolean) {
                    boolean[] arr = new boolean[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        arr[i] = array.getBoolean(i);
                    }
                    builder.setPropertyBoolean(key, arr);
                } else if (first instanceof JSONObject) {
                    GenericDocument[] documentArray = new GenericDocument[array.length()];
                    for (int i = 0; i < array.length(); i++) {
                        documentArray[i] =
                                convertJsonToGenericDocument(array.getJSONObject(i).toString());
                    }
                    builder.setPropertyDocument(key, documentArray);
                }
            }
        }
        return builder.build();
    }

    /**
     * Converts an array of {@link GenericDocument} objects into a {@link JSONArray}.
     *
     * @param documents The array of {@link GenericDocument} to convert.
     * @return A {@link JSONArray} where each element is the JSON representation of a {@link
     *     GenericDocument}.
     * @throws JSONException if there is an error during JSON conversion.
     */
    public static JSONArray convertGenericDocumentsToJsonArray(GenericDocument[] documents)
            throws JSONException {
        JSONArray jsonArray = new JSONArray();
        for (GenericDocument doc : documents) {
            jsonArray.put(convertGenericDocumentToJson(doc));
        }
        return jsonArray;
    }

    /**
     * Converts a single {@link GenericDocument} into a {@link JSONObject}.
     *
     * <p>This method iterates over all properties of the given {@code GenericDocument}. All
     * properties, regardless of whether they are single or repeated, are converted into a {@link
     * JSONArray}.
     *
     * @param genericDocument The {@link GenericDocument} to convert.
     * @return The {@link JSONObject} representation of the document.
     * @throws JSONException if there is an error during JSON conversion.
     */
    public static JSONObject convertGenericDocumentToJson(GenericDocument genericDocument)
            throws JSONException {
        JSONObject jsonObject = new JSONObject();
        Set<String> propertyNames = genericDocument.getPropertyNames();

        for (String propertyName : propertyNames) {
            Object propertyValue = genericDocument.getProperty(propertyName);
            if (propertyValue == null) {
                jsonObject.put(propertyName, JSONObject.NULL);
                continue;
            }

            // HACK: GenericDocument doesn't tell whether a property is singular or repeated.
            // Here, we always convert a property into an array.
            if (propertyValue instanceof GenericDocument[]) {
                GenericDocument[] documentValues = (GenericDocument[]) propertyValue;
                JSONArray jsonArray = new JSONArray();
                for (GenericDocument doc : documentValues) {
                    jsonArray.put(convertGenericDocumentToJson(doc));
                }
                jsonObject.put(propertyName, jsonArray);
            } else if (propertyValue.getClass().isArray()) {
                JSONArray jsonArray = new JSONArray();
                int propertyArrLength = Array.getLength(propertyValue);
                for (int i = 0; i < propertyArrLength; i++) {
                    Object propertyElement = Array.get(propertyValue, i);
                    jsonArray.put(propertyElement);
                }
                jsonObject.put(propertyName, jsonArray);
            }
        }
        return jsonObject;
    }

    /**
     * Converts a {@link SearchResult}, including any nested joined results, into a single {@link
     * JSONObject}.
     *
     * <p>The resulting JSON object uses the schema type of each document as the key for its JSON
     * representation.
     *
     * @param searchResult The {@link SearchResult} to convert.
     * @return A {@link JSONObject} representing the nested structure of the search result.
     * @throws JSONException if there is an error during JSON conversion.
     */
    public static JSONObject searchResultToJsonObject(SearchResult searchResult)
            throws JSONException {
        JSONObject jsonObject = new JSONObject();
        GenericDocument genericDocument = searchResult.getGenericDocument();
        jsonObject.put(
                genericDocument.getSchemaType(),
                AppSearchDataJsonConverter.convertGenericDocumentToJson(genericDocument));

        List<SearchResult> joinedResults = searchResult.getJoinedResults();
        for (SearchResult joinedResult : joinedResults) {
            jsonObject.put(
                    joinedResult.getGenericDocument().getSchemaType(),
                    searchResultToJsonObject(joinedResult));
        }
        return jsonObject;
    }
}
+263 −0

File added.

Preview size limit exceeded, changes collapsed.