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