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