blob: d866da02fb62efc5a6ee05a05d6ae4d37c3c1f4d [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.cts.mockime;
import android.os.Bundle;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import android.view.inputmethod.EditorInfo;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.function.Supplier;
/**
* A utility class that provides basic query operations and wait primitives for a series of
* {@link ImeEvent} sent from the {@link MockIme}.
*
* <p>All public methods are not thread-safe.</p>
*/
public final class ImeEventStream {
private static final String LONG_LONG_SPACES = " ";
private static DateTimeFormatter sSimpleDateTimeFormatter =
DateTimeFormatter.ofPattern("MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault());
@NonNull
private final Supplier<ImeEventArray> mEventSupplier;
private int mCurrentPosition;
ImeEventStream(@NonNull Supplier<ImeEventArray> supplier) {
this(supplier, 0 /* position */);
}
private ImeEventStream(@NonNull Supplier<ImeEventArray> supplier, int position) {
mEventSupplier = supplier;
mCurrentPosition = position;
}
/**
* Create a copy that starts from the same event position of this stream. Once a copy is created
* further event position change on this stream will not affect the copy.
*
* @return A new copy of this stream
*/
public ImeEventStream copy() {
return new ImeEventStream(mEventSupplier, mCurrentPosition);
}
/**
* Advances the current event position by skipping events.
*
* @param length number of events to be skipped
* @throws IllegalArgumentException {@code length} is negative
*/
public void skip(@IntRange(from = 0) int length) {
if (length < 0) {
throw new IllegalArgumentException("length cannot be negative: " + length);
}
mCurrentPosition += length;
}
/**
* Advances the current event position to the next to the last position.
*/
public void skipAll() {
mCurrentPosition = mEventSupplier.get().mLength;
}
/**
* Find the first event that matches the given condition from the current position.
*
* <p>If there is such an event, this method returns such an event without moving the current
* event position.</p>
*
* <p>If there is such an event, this method returns {@link Optional#empty()} without moving the
* current event position.</p>
*
* @param condition the event condition to be matched
* @return {@link Optional#empty()} if there is no such an event. Otherwise the matched event is
* returned
*/
@NonNull
public Optional<ImeEvent> findFirst(Predicate<ImeEvent> condition) {
final ImeEventArray latest = mEventSupplier.get();
int index = mCurrentPosition;
while (true) {
if (index >= latest.mLength) {
return Optional.empty();
}
if (condition.test(latest.mArray[index])) {
return Optional.of(latest.mArray[index]);
}
++index;
}
}
/**
* Find the first event that matches the given condition from the current position.
*
* <p>If there is such an event, this method returns such an event and set the current event
* position to that event.</p>
*
* <p>If there is such an event, this method returns {@link Optional#empty()} without moving the
* current event position.</p>
*
* @param condition the event condition to be matched
* @return {@link Optional#empty()} if there is no such an event. Otherwise the matched event is
* returned
*/
@NonNull
public Optional<ImeEvent> seekToFirst(Predicate<ImeEvent> condition) {
final ImeEventArray latest = mEventSupplier.get();
while (true) {
if (mCurrentPosition >= latest.mLength) {
return Optional.empty();
}
if (condition.test(latest.mArray[mCurrentPosition])) {
return Optional.of(latest.mArray[mCurrentPosition]);
}
++mCurrentPosition;
}
}
private static void dumpEvent(@NonNull StringBuilder sb, @NonNull ImeEvent event,
boolean fused) {
final String indentation = getWhiteSpaces(event.getNestLevel() * 2 + 2);
final long wallTime =
fused ? event.getEnterWallTime() :
event.isEnterEvent() ? event.getEnterWallTime() : event.getExitWallTime();
sb.append(sSimpleDateTimeFormatter.format(Instant.ofEpochMilli(wallTime)))
.append(" ")
.append(String.format("%5d", event.getThreadId()))
.append(indentation);
sb.append(fused ? "" : event.isEnterEvent() ? "[" : "]");
if (fused || event.isEnterEvent()) {
sb.append(event.getEventName())
.append(':')
.append(" args=");
dumpBundle(sb, event.getArguments());
}
sb.append('\n');
}
/**
* @return Debug info as a {@link String}.
*/
public String dump() {
final ImeEventArray latest = mEventSupplier.get();
final StringBuilder sb = new StringBuilder();
sb.append("ImeEventStream:\n");
sb.append(" latest: array[").append(latest.mArray.length).append("] + {\n");
for (int i = 0; i < latest.mLength; ++i) {
// To compress the dump message, if the current event is an enter event and the next
// one is a corresponding exit event, we unify the output.
final boolean fused = areEnterExitPairedMessages(latest, i);
if (i == mCurrentPosition || (fused && ((i + 1) == mCurrentPosition))) {
sb.append(" ======== CurrentPosition ======== \n");
}
dumpEvent(sb, latest.mArray[fused ? ++i : i], fused);
}
if (mCurrentPosition >= latest.mLength) {
sb.append(" ======== CurrentPosition ======== \n");
}
sb.append("}\n");
return sb.toString();
}
/**
* @param array event array to be checked
* @param i index to be checked
* @return {@code true} if {@code array.mArray[i]} and {@code array.mArray[i + 1]} are two
* paired events.
*/
private static boolean areEnterExitPairedMessages(@NonNull ImeEventArray array,
@IntRange(from = 0) int i) {
return array.mArray[i] != null
&& array.mArray[i].isEnterEvent()
&& (i + 1) < array.mLength
&& array.mArray[i + 1] != null
&& array.mArray[i].getEventName().equals(array.mArray[i + 1].getEventName())
&& array.mArray[i].getEnterTimestamp() == array.mArray[i + 1].getEnterTimestamp();
}
/**
* @param length length of the requested white space string
* @return {@link String} object whose length is {@code length}
*/
private static String getWhiteSpaces(@IntRange(from = 0) final int length) {
if (length < LONG_LONG_SPACES.length()) {
return LONG_LONG_SPACES.substring(0, length);
}
final char[] indentationChars = new char[length];
Arrays.fill(indentationChars, ' ');
return new String(indentationChars);
}
private static void dumpBundle(@NonNull StringBuilder sb, @NonNull Bundle bundle) {
sb.append('{');
boolean first = true;
for (String key : bundle.keySet()) {
if (first) {
first = false;
} else {
sb.append(' ');
}
final Object object = bundle.get(key);
sb.append(key);
sb.append('=');
if (object instanceof EditorInfo) {
final EditorInfo info = (EditorInfo) object;
sb.append("EditorInfo{packageName=").append(info.packageName);
sb.append(" fieldId=").append(info.fieldId);
sb.append(" hintText=").append(info.hintText);
sb.append(" privateImeOptions=").append(info.privateImeOptions);
sb.append("}");
} else {
sb.append(object);
}
}
sb.append('}');
}
static class ImeEventArray {
@NonNull
public final ImeEvent[] mArray;
public final int mLength;
ImeEventArray(ImeEvent[] array, int length) {
mArray = array;
mLength = length;
}
}
}