1 /* 2 * Copyright (C) 2017 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.cts.mockime; 18 19 import android.os.Bundle; 20 import android.view.inputmethod.EditorInfo; 21 22 import androidx.annotation.IntRange; 23 import androidx.annotation.NonNull; 24 25 import java.time.Instant; 26 import java.time.ZoneId; 27 import java.time.format.DateTimeFormatter; 28 import java.util.Arrays; 29 import java.util.Optional; 30 import java.util.function.Predicate; 31 import java.util.function.Supplier; 32 33 /** 34 * A utility class that provides basic query operations and wait primitives for a series of 35 * {@link ImeEvent} sent from the {@link MockIme}. 36 * 37 * <p>All public methods are not thread-safe.</p> 38 */ 39 public final class ImeEventStream { 40 41 private static final String LONG_LONG_SPACES = " "; 42 43 private static DateTimeFormatter sSimpleDateTimeFormatter = 44 DateTimeFormatter.ofPattern("MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault()); 45 46 @NonNull 47 private final Supplier<ImeEventArray> mEventSupplier; 48 private int mCurrentPosition; 49 ImeEventStream(@onNull Supplier<ImeEventArray> supplier)50 ImeEventStream(@NonNull Supplier<ImeEventArray> supplier) { 51 this(supplier, 0 /* position */); 52 } 53 ImeEventStream(@onNull Supplier<ImeEventArray> supplier, int position)54 private ImeEventStream(@NonNull Supplier<ImeEventArray> supplier, int position) { 55 mEventSupplier = supplier; 56 mCurrentPosition = position; 57 } 58 59 /** 60 * Create a copy that starts from the same event position of this stream. Once a copy is created 61 * further event position change on this stream will not affect the copy. 62 * 63 * @return A new copy of this stream 64 */ copy()65 public ImeEventStream copy() { 66 return new ImeEventStream(mEventSupplier, mCurrentPosition); 67 } 68 69 /** 70 * Advances the current event position by skipping events. 71 * 72 * @param length number of events to be skipped 73 * @throws IllegalArgumentException {@code length} is negative 74 */ skip(@ntRangefrom = 0) int length)75 public void skip(@IntRange(from = 0) int length) { 76 if (length < 0) { 77 throw new IllegalArgumentException("length cannot be negative: " + length); 78 } 79 mCurrentPosition += length; 80 } 81 82 /** 83 * Advances the current event position to the next to the last position. 84 */ skipAll()85 public void skipAll() { 86 mCurrentPosition = mEventSupplier.get().mLength; 87 } 88 89 /** 90 * Find the first event that matches the given condition from the current position. 91 * 92 * <p>If there is such an event, this method returns such an event without moving the current 93 * event position.</p> 94 * 95 * <p>If there is such an event, this method returns {@link Optional#empty()} without moving the 96 * current event position.</p> 97 * 98 * @param condition the event condition to be matched 99 * @return {@link Optional#empty()} if there is no such an event. Otherwise the matched event is 100 * returned 101 */ 102 @NonNull findFirst(Predicate<ImeEvent> condition)103 public Optional<ImeEvent> findFirst(Predicate<ImeEvent> condition) { 104 final ImeEventArray latest = mEventSupplier.get(); 105 int index = mCurrentPosition; 106 while (true) { 107 if (index >= latest.mLength) { 108 return Optional.empty(); 109 } 110 if (condition.test(latest.mArray[index])) { 111 return Optional.of(latest.mArray[index]); 112 } 113 ++index; 114 } 115 } 116 117 /** 118 * Find the first event that matches the given condition from the current position. 119 * 120 * <p>If there is such an event, this method returns such an event and set the current event 121 * position to that event.</p> 122 * 123 * <p>If there is such an event, this method returns {@link Optional#empty()} without moving the 124 * current event position.</p> 125 * 126 * @param condition the event condition to be matched 127 * @return {@link Optional#empty()} if there is no such an event. Otherwise the matched event is 128 * returned 129 */ 130 @NonNull seekToFirst(Predicate<ImeEvent> condition)131 public Optional<ImeEvent> seekToFirst(Predicate<ImeEvent> condition) { 132 final ImeEventArray latest = mEventSupplier.get(); 133 while (true) { 134 if (mCurrentPosition >= latest.mLength) { 135 return Optional.empty(); 136 } 137 if (condition.test(latest.mArray[mCurrentPosition])) { 138 return Optional.of(latest.mArray[mCurrentPosition]); 139 } 140 ++mCurrentPosition; 141 } 142 } 143 dumpEvent(@onNull StringBuilder sb, @NonNull ImeEvent event, boolean fused)144 private static void dumpEvent(@NonNull StringBuilder sb, @NonNull ImeEvent event, 145 boolean fused) { 146 final String indentation = getWhiteSpaces(event.getNestLevel() * 2 + 2); 147 final long wallTime = 148 fused ? event.getEnterWallTime() : 149 event.isEnterEvent() ? event.getEnterWallTime() : event.getExitWallTime(); 150 sb.append(sSimpleDateTimeFormatter.format(Instant.ofEpochMilli(wallTime))) 151 .append(" ") 152 .append(String.format("%5d", event.getThreadId())) 153 .append(indentation); 154 sb.append(fused ? "" : event.isEnterEvent() ? "[" : "]"); 155 if (fused || event.isEnterEvent()) { 156 sb.append(event.getEventName()) 157 .append(':') 158 .append(" args="); 159 dumpBundle(sb, event.getArguments()); 160 } 161 sb.append('\n'); 162 } 163 164 /** 165 * @return Debug info as a {@link String}. 166 */ dump()167 public String dump() { 168 final ImeEventArray latest = mEventSupplier.get(); 169 final StringBuilder sb = new StringBuilder(); 170 sb.append("ImeEventStream:\n"); 171 sb.append(" latest: array[").append(latest.mArray.length).append("] + {\n"); 172 for (int i = 0; i < latest.mLength; ++i) { 173 // To compress the dump message, if the current event is an enter event and the next 174 // one is a corresponding exit event, we unify the output. 175 final boolean fused = areEnterExitPairedMessages(latest, i); 176 if (i == mCurrentPosition || (fused && ((i + 1) == mCurrentPosition))) { 177 sb.append(" ======== CurrentPosition ======== \n"); 178 } 179 dumpEvent(sb, latest.mArray[fused ? ++i : i], fused); 180 } 181 if (mCurrentPosition >= latest.mLength) { 182 sb.append(" ======== CurrentPosition ======== \n"); 183 } 184 sb.append("}\n"); 185 return sb.toString(); 186 } 187 188 /** 189 * @param array event array to be checked 190 * @param i index to be checked 191 * @return {@code true} if {@code array.mArray[i]} and {@code array.mArray[i + 1]} are two 192 * paired events. 193 */ areEnterExitPairedMessages(@onNull ImeEventArray array, @IntRange(from = 0) int i)194 private static boolean areEnterExitPairedMessages(@NonNull ImeEventArray array, 195 @IntRange(from = 0) int i) { 196 return array.mArray[i] != null 197 && array.mArray[i].isEnterEvent() 198 && (i + 1) < array.mLength 199 && array.mArray[i + 1] != null 200 && array.mArray[i].getEventName().equals(array.mArray[i + 1].getEventName()) 201 && array.mArray[i].getEnterTimestamp() == array.mArray[i + 1].getEnterTimestamp(); 202 } 203 204 /** 205 * @param length length of the requested white space string 206 * @return {@link String} object whose length is {@code length} 207 */ getWhiteSpaces(@ntRangefrom = 0) final int length)208 private static String getWhiteSpaces(@IntRange(from = 0) final int length) { 209 if (length < LONG_LONG_SPACES.length()) { 210 return LONG_LONG_SPACES.substring(0, length); 211 } 212 final char[] indentationChars = new char[length]; 213 Arrays.fill(indentationChars, ' '); 214 return new String(indentationChars); 215 } 216 dumpBundle(@onNull StringBuilder sb, @NonNull Bundle bundle)217 private static void dumpBundle(@NonNull StringBuilder sb, @NonNull Bundle bundle) { 218 sb.append('{'); 219 boolean first = true; 220 for (String key : bundle.keySet()) { 221 if (first) { 222 first = false; 223 } else { 224 sb.append(' '); 225 } 226 final Object object = bundle.get(key); 227 sb.append(key); 228 sb.append('='); 229 if (object instanceof EditorInfo) { 230 final EditorInfo info = (EditorInfo) object; 231 sb.append("EditorInfo{packageName=").append(info.packageName); 232 sb.append(" fieldId=").append(info.fieldId); 233 sb.append(" hintText=").append(info.hintText); 234 sb.append(" privateImeOptions=").append(info.privateImeOptions); 235 sb.append("}"); 236 } else { 237 sb.append(object); 238 } 239 } 240 sb.append('}'); 241 } 242 243 static class ImeEventArray { 244 @NonNull 245 public final ImeEvent[] mArray; 246 public final int mLength; ImeEventArray(ImeEvent[] array, int length)247 ImeEventArray(ImeEvent[] array, int length) { 248 mArray = array; 249 mLength = length; 250 } 251 } 252 } 253