1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.adservices.shell;
18 
19 import android.annotation.IntDef;
20 import android.annotation.Nullable;
21 import android.app.Activity;
22 import android.content.Intent;
23 import android.os.Bundle;
24 import android.util.Log;
25 
26 import com.android.adservices.api.R;
27 import com.android.adservices.concurrency.AdServicesExecutors;
28 import com.android.adservices.service.DebugFlags;
29 import com.android.adservices.service.shell.AdServicesShellCommandHandler;
30 import com.android.adservices.service.shell.AdservicesShellCommandFactorySupplier;
31 import com.android.adservices.service.stats.AdServicesLoggerImpl;
32 
33 import com.google.common.util.concurrent.ListeningExecutorService;
34 
35 import java.io.FileDescriptor;
36 import java.io.PrintWriter;
37 import java.io.StringWriter;
38 import java.lang.annotation.Retention;
39 import java.lang.annotation.RetentionPolicy;
40 import java.util.Arrays;
41 import java.util.concurrent.CountDownLatch;
42 import java.util.concurrent.TimeUnit;
43 import java.util.stream.Collectors;
44 
45 /**
46  * Activity to run shell command for Android R/S.
47  *
48  * <p>It is disabled by default in manifest and is also flag guarded. It doesn't have a launcher
49  * icon. It is first enabled using shell commands. We start an activity by sending an intent with
50  * all the arguments to run the adservices shell command. Then we use dumpsys to get the result of
51  * the already run command. After querying the result, the activity is closed and restored back to
52  * disabled state. Essentially Activity is only alive for the duration of the one command and once
53  * dump cmd is run, it is closed.
54  */
55 public final class ShellCommandActivity extends Activity {
56     // TODO(b/308009734): Add data/info to the UI (input, status, output).
57     // TODO(b/308009734): Add an option to startForResult() that way CTS can use this instead of
58     // dumpsys.
59     private static final String TAG = "AdServicesShellCommand";
60     private static final String CMD_ARGS = "cmd-args";
61     private static final String GET_RESULT_ARG = "get-result";
62     // This timeout is used to get result using dumpsys. The timeout should not be more than 5000
63     // millisecond. Otherwise dumpsys will throw timeout exception.
64     private static final long MAX_COMMAND_DURATION_MILLIS = 3000L;
65 
66     private static final String SHELL_COMMAND_ACTIVITY_DUMP = "-- ShellCommandActivity dump --";
67 
68     private static final String COMMAND_STATUS = "CommandStatus";
69     private static final String COMMAND_RES = "CommandRes";
70     private static final String COMMAND_OUT = "CommandOut";
71     private static final String COMMAND_ERR = "CommandErr";
72 
73     private static final int STATUS_RUNNING = 1;
74     private static final int STATUS_FINISHED = 2;
75     private static final int STATUS_TIMEOUT = 3;
76 
77     @IntDef(value = {STATUS_RUNNING, STATUS_FINISHED, STATUS_TIMEOUT})
78     @Retention(RetentionPolicy.SOURCE)
79     private @interface Status {}
80 
81     private final StringWriter mOutSw = new StringWriter();
82     private final PrintWriter mOutPw = new PrintWriter(mOutSw);
83 
84     private final StringWriter mErrSw = new StringWriter();
85     private final PrintWriter mErrPw = new PrintWriter(mErrSw);
86 
87     private final CountDownLatch mLatch = new CountDownLatch(1);
88 
89     private int mRes = -1;
90     private ListeningExecutorService mExecutorService;
91     private boolean mShellCommandEnabled;
92 
runShellCommand()93     private void runShellCommand() {
94         if (!mShellCommandEnabled) {
95             Log.e(TAG, "Activity started when shell command is disabled");
96             finishSelf();
97             return;
98         }
99 
100         Intent intent = getIntent();
101         String[] args = intent.getStringArrayExtra(CMD_ARGS);
102         if (args == null || args.length == 0) {
103             Log.e(TAG, "command args is null or empty");
104             finishSelf();
105             return;
106         }
107         AdServicesShellCommandHandler handler =
108                 new AdServicesShellCommandHandler(
109                         mOutPw,
110                         mErrPw,
111                         new AdservicesShellCommandFactorySupplier(),
112                         AdServicesLoggerImpl.getInstance());
113         var unused =
114                 mExecutorService.submit(
115                         () -> {
116                             mRes = handler.run(args);
117                             Log.d(TAG, "Shell command completed with status: " + mRes);
118                             mLatch.countDown();
119                         });
120     }
121 
122     @Override
onCreate(Bundle savedInstanceState)123     public void onCreate(Bundle savedInstanceState) {
124         super.onCreate(savedInstanceState);
125         setContentView(R.layout.shell_command_activity);
126 
127         mShellCommandEnabled = DebugFlags.getInstance().getAdServicesShellCommandEnabled();
128         mExecutorService = AdServicesExecutors.getLightWeightExecutor();
129         runShellCommand();
130     }
131 
waitAndGetResult(PrintWriter writer)132     private @Status int waitAndGetResult(PrintWriter writer) {
133         try {
134             if (mLatch.await(MAX_COMMAND_DURATION_MILLIS, TimeUnit.MILLISECONDS)) {
135                 formatAndPrintResult(STATUS_FINISHED, mOutSw.toString(), mErrSw.toString(), writer);
136                 return STATUS_FINISHED;
137             }
138             Log.e(
139                     TAG,
140                     String.format(
141                             "Elapsed time: %d Millisecond. Command is still running. Please"
142                                     + " retry by calling get-result shell command\n",
143                             MAX_COMMAND_DURATION_MILLIS));
144             formatAndPrintResult(STATUS_RUNNING, mOutSw.toString(), mErrSw.toString(), writer);
145             return STATUS_RUNNING;
146         } catch (InterruptedException e) {
147             writer.println("Thread interrupted, failed to complete shell command");
148             Thread.currentThread().interrupt();
149         }
150         return STATUS_FINISHED;
151     }
152 
153     @Override
dump( String prefix, @Nullable FileDescriptor fd, PrintWriter writer, @Nullable String[] args)154     public void dump(
155             String prefix,
156             @Nullable FileDescriptor fd,
157             PrintWriter writer,
158             @Nullable String[] args) {
159         // Get the result after the starting activity using dumpsys.
160         if (args != null && args.length > 0 && args[0].equals("cmd")) {
161             getShellCommandResult(args, writer);
162             return;
163         }
164 
165         super.dump(prefix, fd, writer, args);
166     }
167 
getShellCommandResult(String[] args, PrintWriter writer)168     private void getShellCommandResult(String[] args, PrintWriter writer) {
169         if (!mShellCommandEnabled) {
170             Log.e(
171                     TAG,
172                     String.format(
173                             "dump(%s) called on ShellCommandActivity when shell"
174                                     + " command flag was disabled",
175                             Arrays.toString(args)));
176             return;
177         }
178 
179         if (args.length == 2 && args[1].equals(GET_RESULT_ARG)) {
180             @Status int status = waitAndGetResult(writer);
181             if (status != STATUS_RUNNING) {
182                 finishSelf();
183             }
184         } else {
185             writer.printf(
186                     "Invalid args %s. Supported args are 'cmd get-result'\n",
187                     Arrays.toString(args));
188             finishSelf();
189         }
190     }
191 
192     /*
193     Example formatted result:
194       -- ShellCommandActivity dump --
195       CommandStatus: FINISHED
196       CommandRes: 0
197       CommandOut:
198         hello
199       CommandErr:
200         Something went wrong
201      */
formatAndPrintResult( @tatus int status, String out, String err, PrintWriter writer)202     private void formatAndPrintResult(
203             @Status int status, String out, String err, PrintWriter writer) {
204         writer.println(SHELL_COMMAND_ACTIVITY_DUMP);
205         writer.printf("%s: %s\n", COMMAND_STATUS, statusToString(status));
206 
207         if (status == STATUS_FINISHED || status == STATUS_TIMEOUT) {
208             writer.printf("%s: %d\n", COMMAND_RES, mRes);
209         }
210         if (!out.isEmpty()) {
211             indentAndPrefixStringWithSpace(out, COMMAND_OUT, writer);
212         }
213         if (!err.isEmpty()) {
214             indentAndPrefixStringWithSpace(err, COMMAND_ERR, writer);
215         }
216     }
217 
indentAndPrefixStringWithSpace(String input, String name, PrintWriter writer)218     private void indentAndPrefixStringWithSpace(String input, String name, PrintWriter writer) {
219         String formattedOut =
220                 Arrays.stream(input.strip().split("\n"))
221                         .collect(Collectors.joining("\n  ", "  ", ""));
222         writer.printf("%s:\n%s\n", name, formattedOut);
223     }
224 
statusToString(int status)225     private String statusToString(int status) {
226         switch (status) {
227             case STATUS_RUNNING:
228                 return "RUNNING";
229             case STATUS_FINISHED:
230                 return "FINISHED";
231             case STATUS_TIMEOUT:
232                 return "TIMEOUT";
233             default:
234                 return "UNKNOWN-" + status;
235         }
236     }
237 
finishSelf()238     private void finishSelf() {
239         mOutPw.close();
240         finish();
241     }
242 }
243