1 /*
2  * Copyright (C) 2022 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.google.android.car.kitchensink.touch;
18 
19 import static android.view.WindowManager.LayoutParams.MATCH_PARENT;
20 import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
21 
22 import android.car.CarOccupantZoneManager;
23 import android.car.CarOccupantZoneManager.OccupantZoneInfo;
24 import android.graphics.PixelFormat;
25 import android.hardware.display.DisplayManager;
26 import android.os.Bundle;
27 import android.os.Handler;
28 import android.os.Process;
29 import android.util.Log;
30 import android.view.Display;
31 import android.view.LayoutInflater;
32 import android.view.MotionEvent;
33 import android.view.View;
34 import android.view.ViewGroup;
35 import android.view.WindowManager;
36 import android.widget.ArrayAdapter;
37 import android.widget.Spinner;
38 
39 import androidx.annotation.NonNull;
40 import androidx.annotation.Nullable;
41 import androidx.fragment.app.Fragment;
42 
43 import com.google.android.car.kitchensink.KitchenSinkActivity;
44 import com.google.android.car.kitchensink.R;
45 
46 import java.io.DataOutputStream;
47 import java.util.ArrayList;
48 import java.util.List;
49 
50 public final class InjectMotionTestFragment extends Fragment {
51     private static final String TAG = InjectMotionTestFragment.class.getSimpleName();
52 
53     private static final String SHELL_CMD = "sh";
54     private static final String PREFIX_INJECTING_MOTION_CMD = "cmd car_service inject-motion";
55     private static final String OPTION_SEAT = " -s ";
56     private static final String OPTION_ACTION = " -a ";
57     private static final String OPTION_COUNT = " -c ";
58     private static final String OPTION_POINTER_ID = " -p";
59 
60     // Only half of {@link MotionEvent#ACTION_MOVE} events are sampled to reduce latency.
61     // Events can be delayed with shell commands using {@link Runtime#exec()}. Depending on hardware
62     // specifications, on some devices sampling only half of the events may still cause a delay.
63     private static final int TOUCH_SAMPLING_RATE = 2; // 50%
64 
65     private java.lang.Process mShellProcess;
66     private DataOutputStream mOutStreamForShell;
67 
68     private DisplayManager mDisplayManager;
69     private Display mCurrentDisplay;
70     private Display mTargetDisplay;
71 
72     private boolean mIsSelected;
73 
74     private Spinner mDisplaySpinner;
75     private Spinner mSamplingRateSpinner;
76     private CarOccupantZoneManager mOccupantZoneManager;
77     private WindowManager mWindowManager;
78     private ViewGroup mTouchPointView;
79     private int mCountOfMoveEvent = 0;
80 
81     @Override
onCreate(@ullable Bundle savedInstanceState)82     public void onCreate(@Nullable Bundle savedInstanceState) {
83         super.onCreate(savedInstanceState);
84 
85         mDisplayManager = getContext().getSystemService(DisplayManager.class);
86         mCurrentDisplay = getContext().getDisplay();
87 
88         final Runnable r = () -> {
89             mOccupantZoneManager = ((KitchenSinkActivity) getActivity()).getOccupantZoneManager();
90         };
91         ((KitchenSinkActivity) getActivity()).requestRefreshManager(r,
92                 new Handler(getContext().getMainLooper()));
93     }
94 
95     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle)96     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) {
97         View view = inflater.inflate(R.layout.injecting_touch_fragment, container, false);
98         mDisplaySpinner = view.findViewById(R.id.display_select_spinner);
99         mTouchPointView = (ViewGroup) LayoutInflater.from(getContext())
100                 .inflate(R.layout.injecting_touch_point_view, /* root= */ null);
101         return view;
102     }
103 
104     @Override
onViewCreated(@onNull View view, @Nullable Bundle savedInstanceState)105     public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
106         super.onViewCreated(view, savedInstanceState);
107         mDisplaySpinner.setAdapter(new ArrayAdapter<>(getContext(),
108                 android.R.layout.simple_spinner_item, getDisplays()));
109         view.findViewById(R.id.select_start_button).setOnClickListener((v) -> startInjecting());
110         view.findViewById(R.id.select_stop_button).setOnClickListener((v) -> stopInjecting());
111         view.findViewById(R.id.touch_point_view).setOnTouchListener(this::onTouchView);
112         updateUi();
113     }
114 
onTouchView(View v, MotionEvent event)115     public boolean onTouchView(View v, MotionEvent event) {
116         if (shouldDropEvent(event)) {
117             return true;
118         }
119 
120         return injectMotionByShell(event);
121     }
122 
123     @Override
onDestroyView()124     public void onDestroyView() {
125         removeTouchPointView();
126         mTouchPointView = null;
127         super.onDestroyView();
128     }
129 
130     @Override
onPause()131     public void onPause() {
132         if (mIsSelected) {
133             stopInjecting();
134         }
135         super.onPause();
136     }
137 
shouldDropEvent(MotionEvent event)138     private boolean shouldDropEvent(MotionEvent event) {
139         if (!mIsSelected) {
140             return false;
141         }
142 
143         switch (event.getAction()) {
144             case MotionEvent.ACTION_DOWN:
145                 mCountOfMoveEvent = 0;
146                 break;
147             case MotionEvent.ACTION_MOVE:
148                 mCountOfMoveEvent++;
149                 if (mCountOfMoveEvent == Integer.MAX_VALUE) {
150                     mCountOfMoveEvent = 1;
151                 }
152                 if (mCountOfMoveEvent % TOUCH_SAMPLING_RATE != 0) {
153                     return true;
154                 }
155                 break;
156             default:
157                 break;
158         }
159 
160         return false;
161     }
162 
injectMotionByShell(MotionEvent event)163     private boolean injectMotionByShell(MotionEvent event) {
164         if (!mIsSelected) {
165             return false;
166         }
167         if (mTargetDisplay == null) {
168             return false;
169         }
170         if (mShellProcess == null || mOutStreamForShell == null) {
171             return false;
172         }
173 
174         OccupantZoneInfo zone = getOccupantZoneForDisplayId(
175                 mTargetDisplay.getDisplayId());
176         if (zone != null) {
177             // use raw screen X and Y coordinates instead of window coordinates.
178             float deltaX = event.getRawX() - event.getX();
179             float deltaY = event.getRawY() - event.getY();
180 
181             // generate a command message
182             StringBuilder sb = new StringBuilder()
183                     .append(PREFIX_INJECTING_MOTION_CMD)
184                     .append(OPTION_SEAT)
185                     .append(zone.seat)
186                     .append(OPTION_ACTION)
187                     .append(event.getAction())
188                     .append(OPTION_COUNT)
189                     .append(event.getPointerCount());
190             sb.append(OPTION_POINTER_ID);
191             for (int i = 0; i < event.getPointerCount(); i++) {
192                 int pointerId = event.getPointerId(i);
193                 sb.append(' ');
194                 sb.append(pointerId);
195             }
196             for (int i = 0; i < event.getPointerCount(); i++) {
197                 int pointerId = event.getPointerId(i);
198                 int pointerIndex = event.findPointerIndex(pointerId);
199                 float x = event.getX(pointerIndex) + deltaX;
200                 float y = event.getY(pointerIndex) + deltaY;
201                 sb.append(' ');
202                 sb.append(x);
203                 sb.append(' ');
204                 sb.append(y);
205             }
206             sb.append('\n');
207 
208             try {
209                 // send the command to shell
210                 mOutStreamForShell.writeBytes(sb.toString());
211                 mOutStreamForShell.flush();
212             } catch (Exception e) {
213                 Log.e(TAG, "Cannot flush", e);
214             }
215         }
216         return true;
217     }
218 
openShellSession()219     private void openShellSession() {
220         try {
221             mShellProcess = Runtime.getRuntime().exec(SHELL_CMD);
222             mOutStreamForShell = new DataOutputStream(mShellProcess.getOutputStream());
223         } catch (Exception e) {
224             Log.e(TAG, "Cannot execute shell", e);
225             mShellProcess = null;
226             mOutStreamForShell = null;
227         }
228     }
229 
closeShellSession()230     private void closeShellSession() {
231         if (mShellProcess == null) {
232             return;
233         }
234 
235         try {
236             mShellProcess.getErrorStream().close();
237             mShellProcess.getInputStream().close();
238             mShellProcess.getOutputStream().close();
239             mShellProcess.waitFor();
240         } catch (Exception e) {
241             Log.e(TAG, "Cannot close streams", e);
242         } finally {
243             mShellProcess = null;
244             mOutStreamForShell = null;
245         }
246     }
247 
248     // It is used to find a seat for the display id.
getOccupantZoneForDisplayId(int displayId)249     private OccupantZoneInfo getOccupantZoneForDisplayId(int displayId) {
250         List<OccupantZoneInfo> zones = mOccupantZoneManager.getAllOccupantZones();
251         for (OccupantZoneInfo zone : zones) {
252             List<Display> displays = mOccupantZoneManager.getAllDisplaysForOccupant(zone);
253             for (Display disp : displays) {
254                 if (disp.getDisplayId() == displayId) {
255                     return zone;
256                 }
257             }
258         }
259         return null;
260     }
261 
262     /**
263      * Start selecting the target display for test.
264      */
startInjecting()265     private void startInjecting() {
266         int selectedDisplayId = (Integer) mDisplaySpinner.getSelectedItem();
267         mTargetDisplay = mDisplayManager.getDisplay(selectedDisplayId);
268         showTouchPointView(mTargetDisplay);
269         mIsSelected = true;
270         openShellSession();
271         updateUi();
272     }
273 
274     /**
275      * Stop selecting  the target display for test.
276      */
stopInjecting()277     private void stopInjecting() {
278         removeTouchPointView();
279         mTargetDisplay = null;
280         mIsSelected = false;
281         closeShellSession();
282         updateUi();
283     }
284 
285     /**
286      * Update a touch point view and control buttons UI.
287      */
updateUi()288     private void updateUi() {
289         getView().findViewById(R.id.touch_point_view).setVisibility(
290                 mIsSelected ? View.VISIBLE : View.INVISIBLE);
291         getView().findViewById(R.id.select_start_button).setEnabled(!mIsSelected);
292         mDisplaySpinner.setEnabled(!mIsSelected);
293         getView().findViewById(R.id.select_stop_button).setEnabled(mIsSelected);
294     }
295 
296     /**
297      * Shows a touch point view on the specified display.
298      */
showTouchPointView(Display display)299     private void showTouchPointView(Display display) {
300         final int displayId = display.getDisplayId();
301         if (mWindowManager != null) {
302             Log.w(TAG, "Active window exists on Display #" + displayId + ".");
303             return;
304         }
305         mWindowManager = getContext().createWindowContext(display, TYPE_APPLICATION_OVERLAY,
306                 /* options= */ null).getSystemService(WindowManager.class);
307         WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
308                 MATCH_PARENT,
309                 MATCH_PARENT,
310                 TYPE_APPLICATION_OVERLAY,
311                 /* flags= */ 0,
312                 PixelFormat.RGBA_8888);
313         mWindowManager.addView(mTouchPointView, lp);
314     }
315 
316     /**
317      * Removes a touch point view on the specified display.
318      */
removeTouchPointView()319     private void removeTouchPointView() {
320         if (mWindowManager == null) {
321             return;
322         }
323         mWindowManager.removeView(mTouchPointView);
324         mWindowManager = null;
325     }
326 
getDisplays()327     private ArrayList<Integer> getDisplays() {
328         ArrayList<Integer> displayIds = new ArrayList<>();
329         Display[] displays = mDisplayManager.getDisplays();
330         int uidSelf = Process.myUid();
331         for (Display disp : displays) {
332             if (!disp.hasAccess(uidSelf)) {
333                 Log.d(TAG, "Cannot access the display: displayId=" + disp.getDisplayId());
334                 continue;
335             }
336             if (mCurrentDisplay != null && disp.getDisplayId() == mCurrentDisplay.getDisplayId()) {
337                 // skip the current display
338                 continue;
339             }
340             displayIds.add(disp.getDisplayId());
341         }
342         return displayIds;
343     }
344 }
345