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