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