/* * Copyright (C) 2021 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 android.view.translation; import static android.view.translation.Helper.ANIMATION_DURATION_MILLIS; import static android.view.translation.UiTranslationManager.STATE_UI_TRANSLATION_FINISHED; import static android.view.translation.UiTranslationManager.STATE_UI_TRANSLATION_PAUSED; import static android.view.translation.UiTranslationManager.STATE_UI_TRANSLATION_RESUMED; import static android.view.translation.UiTranslationManager.STATE_UI_TRANSLATION_STARTED; import android.annotation.NonNull; import android.annotation.WorkerThread; import android.app.Activity; import android.app.assist.ActivityId; import android.content.Context; import android.os.Bundle; import android.os.Handler; import android.os.HandlerThread; import android.os.Process; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Dumpable; import android.util.IntArray; import android.util.Log; import android.util.LongSparseArray; import android.util.Pair; import android.util.SparseArray; import android.util.SparseIntArray; import android.view.View; import android.view.ViewGroup; import android.view.ViewRootImpl; import android.view.WindowManagerGlobal; import android.view.autofill.AutofillId; import android.view.translation.UiTranslationManager.UiTranslationState; import android.widget.TextView; import android.widget.TextViewTranslationCallback; import com.android.internal.util.function.pooled.PooledLambda; import java.io.PrintWriter; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.BiConsumer; /** * A controller to manage the ui translation requests for the {@link Activity}. * * @hide */ public class UiTranslationController implements Dumpable { public static final boolean DEBUG = Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG); /** @hide */ public static final String DUMPABLE_NAME = "UiTranslationController"; private static final String TAG = "UiTranslationController"; @NonNull private final Activity mActivity; @NonNull private final Context mContext; @NonNull private final Object mLock = new Object(); // Each Translator is distinguished by sourceSpec and desSepc. @NonNull private final ArrayMap, Translator> mTranslators; @NonNull private final ArrayMap> mViews; /** * Views for which {@link UiTranslationSpec#shouldPadContentForCompat()} is true. */ @NonNull private final ArraySet mViewsToPadContent; @NonNull private final HandlerThread mWorkerThread; @NonNull private final Handler mWorkerHandler; private int mCurrentState; @NonNull private ArraySet mLastRequestAutofillIds; public UiTranslationController(Activity activity, Context context) { mActivity = activity; mContext = context; mViews = new ArrayMap<>(); mTranslators = new ArrayMap<>(); mViewsToPadContent = new ArraySet<>(); mWorkerThread = new HandlerThread("UiTranslationController_" + mActivity.getComponentName(), Process.THREAD_PRIORITY_FOREGROUND); mWorkerThread.start(); mWorkerHandler = mWorkerThread.getThreadHandler(); activity.addDumpable(this); } /** * Update the Ui translation state. */ public void updateUiTranslationState(@UiTranslationState int state, TranslationSpec sourceSpec, TranslationSpec targetSpec, List views, UiTranslationSpec uiTranslationSpec) { if (mActivity.isDestroyed()) { Log.i(TAG, "Cannot update " + stateToString(state) + " for destroyed " + mActivity); return; } boolean isLoggable = Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG); Log.i(TAG, "updateUiTranslationState state: " + stateToString(state) + (isLoggable ? (", views: " + views + ", spec: " + uiTranslationSpec) : "")); synchronized (mLock) { mCurrentState = state; if (views != null) { setLastRequestAutofillIdsLocked(views); } } switch (state) { case STATE_UI_TRANSLATION_STARTED: if (uiTranslationSpec != null && uiTranslationSpec.shouldPadContentForCompat()) { synchronized (mLock) { mViewsToPadContent.addAll(views); // TODO: Cleanup disappeared views from mViews and mViewsToPadContent at // some appropriate place. } } final Pair specs = new Pair<>(sourceSpec, targetSpec); if (!mTranslators.containsKey(specs)) { mWorkerHandler.sendMessage(PooledLambda.obtainMessage( UiTranslationController::createTranslatorAndStart, UiTranslationController.this, sourceSpec, targetSpec, views)); } else { onUiTranslationStarted(mTranslators.get(specs), views); } break; case STATE_UI_TRANSLATION_PAUSED: runForEachView((view, callback) -> callback.onHideTranslation(view)); break; case STATE_UI_TRANSLATION_RESUMED: runForEachView((view, callback) -> callback.onShowTranslation(view)); break; case STATE_UI_TRANSLATION_FINISHED: destroyTranslators(); runForEachView((view, callback) -> { view.clearTranslationState(); }); notifyTranslationFinished(/* activityDestroyed= */ false); synchronized (mLock) { mViews.clear(); } break; default: Log.w(TAG, "onAutoTranslationStateChange(): unknown state: " + state); } } /** * Called when the Activity is destroyed. */ public void onActivityDestroyed() { synchronized (mLock) { Log.i(TAG, "onActivityDestroyed(): mCurrentState is " + stateToString(mCurrentState)); if (mCurrentState != STATE_UI_TRANSLATION_FINISHED) { notifyTranslationFinished(/* activityDestroyed= */ true); } mViews.clear(); destroyTranslators(); mWorkerThread.quitSafely(); } } private void notifyTranslationFinished(boolean activityDestroyed) { UiTranslationManager manager = mContext.getSystemService(UiTranslationManager.class); if (manager != null) { manager.onTranslationFinished(activityDestroyed, new ActivityId(mActivity.getTaskId(), mActivity.getShareableActivityToken()), mActivity.getComponentName()); } } private void setLastRequestAutofillIdsLocked(List views) { if (mLastRequestAutofillIds == null) { mLastRequestAutofillIds = new ArraySet<>(); } if (mLastRequestAutofillIds.size() > 0) { mLastRequestAutofillIds.clear(); } mLastRequestAutofillIds.addAll(views); } @Override public String getDumpableName() { return DUMPABLE_NAME; } @Override public void dump(PrintWriter pw, String[] args) { String outerPrefix = ""; pw.print(outerPrefix); pw.println("UiTranslationController:"); final String pfx = outerPrefix + " "; pw.print(pfx); pw.print("activity: "); pw.print(mActivity); pw.print(pfx); pw.print("resumed: "); pw.println(mActivity.isResumed()); pw.print(pfx); pw.print("current state: "); pw.println(mCurrentState); final int translatorSize = mTranslators.size(); pw.print(outerPrefix); pw.print("number translator: "); pw.println(translatorSize); for (int i = 0; i < translatorSize; i++) { pw.print(outerPrefix); pw.print("#"); pw.println(i); final Translator translator = mTranslators.valueAt(i); translator.dump(outerPrefix, pw); pw.println(); } synchronized (mLock) { final int viewSize = mViews.size(); pw.print(outerPrefix); pw.print("number views: "); pw.println(viewSize); for (int i = 0; i < viewSize; i++) { pw.print(outerPrefix); pw.print("#"); pw.println(i); final AutofillId autofillId = mViews.keyAt(i); final View view = mViews.valueAt(i).get(); pw.print(pfx); pw.print("autofillId: "); pw.println(autofillId); pw.print(pfx); pw.print("view:"); pw.println(view); } pw.print(outerPrefix); pw.print("padded views: "); pw.println(mViewsToPadContent); } if (Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG)) { dumpViewByTraversal(outerPrefix, pw); } } private void dumpViewByTraversal(String outerPrefix, PrintWriter pw) { final ArrayList roots = WindowManagerGlobal.getInstance().getRootViews(mActivity.getActivityToken()); pw.print(outerPrefix); pw.println("Dump views:"); for (int rootNum = 0; rootNum < roots.size(); rootNum++) { final View rootView = roots.get(rootNum).getView(); if (rootView instanceof ViewGroup) { dumpChildren((ViewGroup) rootView, outerPrefix, pw); } else { dumpViewInfo(rootView, outerPrefix, pw); } } } private void dumpChildren(ViewGroup viewGroup, String outerPrefix, PrintWriter pw) { final int childCount = viewGroup.getChildCount(); for (int i = 0; i < childCount; ++i) { final View child = viewGroup.getChildAt(i); if (child instanceof ViewGroup) { pw.print(outerPrefix); pw.println("Children: "); pw.print(outerPrefix); pw.print(outerPrefix); pw.println(child); dumpChildren((ViewGroup) child, outerPrefix, pw); } else { pw.print(outerPrefix); pw.println("End Children: "); pw.print(outerPrefix); pw.print(outerPrefix); pw.print(child); dumpViewInfo(child, outerPrefix, pw); } } } private void dumpViewInfo(View view, String outerPrefix, PrintWriter pw) { final AutofillId autofillId = view.getAutofillId(); pw.print(outerPrefix); pw.print("autofillId: "); pw.print(autofillId); // TODO: print TranslationTransformation boolean isContainsView = false; boolean isRequestedView = false; synchronized (mLock) { if (mLastRequestAutofillIds.contains(autofillId)) { isRequestedView = true; } final WeakReference viewRef = mViews.get(autofillId); if (viewRef != null && viewRef.get() != null) { isContainsView = true; } } pw.print(outerPrefix); pw.print("isContainsView: "); pw.print(isContainsView); pw.print(outerPrefix); pw.print("isRequestedView: "); pw.println(isRequestedView); } /** * The method is used by {@link Translator}, it will be called when the translation is done. The * translation result can be get from here. */ public void onTranslationCompleted(TranslationResponse response) { if (response == null || response.getTranslationStatus() != TranslationResponse.TRANSLATION_STATUS_SUCCESS) { Log.w(TAG, "Fail result from TranslationService, status=" + (response == null ? "null" : response.getTranslationStatus())); return; } final SparseArray translatedResult = response.getViewTranslationResponses(); final SparseArray viewsResult = new SparseArray<>(); final SparseArray> virtualViewsResult = new SparseArray<>(); final IntArray viewIds = new IntArray(1); for (int i = 0; i < translatedResult.size(); i++) { final ViewTranslationResponse result = translatedResult.valueAt(i); final AutofillId autofillId = result.getAutofillId(); if (viewIds.indexOf(autofillId.getViewId()) < 0) { viewIds.add(autofillId.getViewId()); } if (autofillId.isNonVirtual()) { viewsResult.put(translatedResult.keyAt(i), result); } else { final boolean isVirtualViewAdded = virtualViewsResult.indexOfKey(autofillId.getViewId()) >= 0; final LongSparseArray childIds = isVirtualViewAdded ? virtualViewsResult.get(autofillId.getViewId()) : new LongSparseArray<>(); childIds.put(autofillId.getVirtualChildLongId(), result); if (!isVirtualViewAdded) { virtualViewsResult.put(autofillId.getViewId(), childIds); } } } // Traverse tree and get views by the responsed AutofillId findViewsTraversalByAutofillIds(viewIds); if (viewsResult.size() > 0) { onTranslationCompleted(viewsResult); } if (virtualViewsResult.size() > 0) { onVirtualViewTranslationCompleted(virtualViewsResult); } } /** * The method is used to handle the translation result for the vertual views. */ private void onVirtualViewTranslationCompleted( SparseArray> translatedResult) { boolean isLoggable = Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG); if (mActivity.isDestroyed()) { Log.v(TAG, "onTranslationCompleted:" + mActivity + "is destroyed."); return; } synchronized (mLock) { if (mCurrentState == STATE_UI_TRANSLATION_FINISHED) { Log.w(TAG, "onTranslationCompleted: the translation state is finished now. " + "Skip to show the translated text."); return; } for (int i = 0; i < translatedResult.size(); i++) { final AutofillId autofillId = new AutofillId(translatedResult.keyAt(i)); final WeakReference viewRef = mViews.get(autofillId); if (viewRef == null) { continue; } final View view = viewRef.get(); if (view == null) { Log.w(TAG, "onTranslationCompleted: the view for autofill id " + autofillId + " may be gone."); continue; } final LongSparseArray virtualChildResponse = translatedResult.valueAt(i); if (isLoggable) { Log.v(TAG, "onVirtualViewTranslationCompleted: received response for " + "AutofillId " + autofillId); } view.onVirtualViewTranslationResponses(virtualChildResponse); if (mCurrentState == STATE_UI_TRANSLATION_PAUSED) { return; } mActivity.runOnUiThread(() -> { if (view.getViewTranslationCallback() == null) { if (isLoggable) { Log.d(TAG, view + " doesn't support showing translation because of " + "null ViewTranslationCallback."); } return; } if (view.getViewTranslationCallback() != null) { view.getViewTranslationCallback().onShowTranslation(view); } }); } } } /** * The method is used to handle the translation result for non-vertual views. */ private void onTranslationCompleted(SparseArray translatedResult) { boolean isLoggable = Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG); if (mActivity.isDestroyed()) { Log.v(TAG, "onTranslationCompleted:" + mActivity + "is destroyed."); return; } final int resultCount = translatedResult.size(); if (isLoggable) { Log.v(TAG, "onTranslationCompleted: receive " + resultCount + " responses."); } synchronized (mLock) { if (mCurrentState == STATE_UI_TRANSLATION_FINISHED) { Log.w(TAG, "onTranslationCompleted: the translation state is finished now. " + "Skip to show the translated text."); return; } for (int i = 0; i < resultCount; i++) { final ViewTranslationResponse response = translatedResult.valueAt(i); if (isLoggable) { Log.v(TAG, "onTranslationCompleted: " + sanitizedViewTranslationResponse(response)); } final AutofillId autofillId = response.getAutofillId(); if (autofillId == null) { Log.w(TAG, "No AutofillId is set in ViewTranslationResponse"); continue; } final WeakReference viewRef = mViews.get(autofillId); if (viewRef == null) { continue; } final View view = viewRef.get(); if (view == null) { Log.w(TAG, "onTranslationCompleted: the view for autofill id " + autofillId + " may be gone."); continue; } int currentState; currentState = mCurrentState; mActivity.runOnUiThread(() -> { ViewTranslationCallback callback = view.getViewTranslationCallback(); if (view.getViewTranslationResponse() != null && view.getViewTranslationResponse().equals(response)) { if (callback instanceof TextViewTranslationCallback) { TextViewTranslationCallback textViewCallback = (TextViewTranslationCallback) callback; if (textViewCallback.isShowingTranslation() || textViewCallback.isAnimationRunning()) { if (isLoggable) { Log.d(TAG, "Duplicate ViewTranslationResponse for " + autofillId + ". Ignoring."); } return; } } } if (callback == null) { if (view instanceof TextView) { // developer doesn't provide their override, we set the default TextView // implementation. callback = new TextViewTranslationCallback(); view.setViewTranslationCallback(callback); } else { if (isLoggable) { Log.d(TAG, view + " doesn't support showing translation because of " + "null ViewTranslationCallback."); } return; } } callback.setAnimationDurationMillis(ANIMATION_DURATION_MILLIS); if (mViewsToPadContent.contains(autofillId)) { callback.enableContentPadding(); } view.onViewTranslationResponse(response); if (currentState == STATE_UI_TRANSLATION_PAUSED) { return; } callback.onShowTranslation(view); }); } } } /** * Creates a Translator for the given source and target translation specs and start the ui * translation when the Translator is created successfully. */ @WorkerThread private void createTranslatorAndStart(TranslationSpec sourceSpec, TranslationSpec targetSpec, List views) { // Create Translator final Translator translator = createTranslatorIfNeeded(sourceSpec, targetSpec); if (translator == null) { Log.w(TAG, "Can not create Translator for sourceSpec:" + sourceSpec + " targetSpec:" + targetSpec); return; } onUiTranslationStarted(translator, views); } @WorkerThread private void sendTranslationRequest(Translator translator, List requests) { if (requests.size() == 0) { Log.w(TAG, "No ViewTranslationRequest was collected."); return; } final TranslationRequest request = new TranslationRequest.Builder() .setViewTranslationRequests(requests) .build(); if (Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG)) { StringBuilder msg = new StringBuilder("sendTranslationRequest:{requests=["); for (ViewTranslationRequest viewRequest: requests) { msg.append("{request=") .append(sanitizedViewTranslationRequest(viewRequest)) .append("}, "); } Log.d(TAG, "sendTranslationRequest: " + msg.toString()); } translator.requestUiTranslate(request, (r) -> r.run(), this::onTranslationCompleted); } /** * Called when there is an ui translation request comes to request view translation. */ private void onUiTranslationStarted(Translator translator, List views) { synchronized (mLock) { // Filter the request views' AutofillId SparseIntArray virtualViewChildCount = getRequestVirtualViewChildCount(views); Map viewIds = new ArrayMap<>(); Map unusedIndices = null; for (int i = 0; i < views.size(); i++) { AutofillId autofillId = views.get(i); if (autofillId.isNonVirtual()) { viewIds.put(autofillId, null); } else { if (unusedIndices == null) { unusedIndices = new ArrayMap<>(); } // The virtual id get from content capture is long, see getVirtualChildLongId() // e.g. 1001, 1001:2, 1002:1 -> 1001, <1,2>; 1002, <1> AutofillId virtualViewAutofillId = new AutofillId(autofillId.getViewId()); long[] childs; int end = 0; if (viewIds.containsKey(virtualViewAutofillId)) { childs = viewIds.get(virtualViewAutofillId); end = unusedIndices.get(virtualViewAutofillId); } else { int childCount = virtualViewChildCount.get(autofillId.getViewId()); childs = new long[childCount]; viewIds.put(virtualViewAutofillId, childs); } unusedIndices.put(virtualViewAutofillId, end + 1); childs[end] = autofillId.getVirtualChildLongId(); } } ArrayList requests = new ArrayList<>(); int[] supportedFormats = getSupportedFormatsLocked(); ArrayList roots = WindowManagerGlobal.getInstance().getRootViews(mActivity.getActivityToken()); TranslationCapability capability = getTranslationCapability(translator.getTranslationContext()); mActivity.runOnUiThread(() -> { // traverse the hierarchy to collect ViewTranslationRequests for (int rootNum = 0; rootNum < roots.size(); rootNum++) { View rootView = roots.get(rootNum).getView(); rootView.dispatchCreateViewTranslationRequest(viewIds, supportedFormats, capability, requests); } mWorkerHandler.sendMessage(PooledLambda.obtainMessage( UiTranslationController::sendTranslationRequest, UiTranslationController.this, translator, requests)); }); } } private SparseIntArray getRequestVirtualViewChildCount(List views) { SparseIntArray virtualViewCount = new SparseIntArray(); for (int i = 0; i < views.size(); i++) { AutofillId autofillId = views.get(i); if (!autofillId.isNonVirtual()) { int virtualViewId = autofillId.getViewId(); if (virtualViewCount.indexOfKey(virtualViewId) < 0) { virtualViewCount.put(virtualViewId, 1); } else { virtualViewCount.put(virtualViewId, (virtualViewCount.get(virtualViewId) + 1)); } } } return virtualViewCount; } private int[] getSupportedFormatsLocked() { // We only support text now return new int[] {TranslationSpec.DATA_FORMAT_TEXT}; } private TranslationCapability getTranslationCapability(TranslationContext translationContext) { // We only support text to text capability now, we will query real status from service when // we support more translation capabilities. return new TranslationCapability(TranslationCapability.STATE_ON_DEVICE, translationContext.getSourceSpec(), translationContext.getTargetSpec(), /* uiTranslationEnabled= */ true, /* supportedTranslationFlags= */ 0); } private void findViewsTraversalByAutofillIds(IntArray sourceViewIds) { final ArrayList roots = WindowManagerGlobal.getInstance().getRootViews(mActivity.getActivityToken()); for (int rootNum = 0; rootNum < roots.size(); rootNum++) { final View rootView = roots.get(rootNum).getView(); if (rootView instanceof ViewGroup) { findViewsTraversalByAutofillIds((ViewGroup) rootView, sourceViewIds); } addViewIfNeeded(sourceViewIds, rootView); } } private void findViewsTraversalByAutofillIds(ViewGroup viewGroup, IntArray sourceViewIds) { final int childCount = viewGroup.getChildCount(); for (int i = 0; i < childCount; ++i) { final View child = viewGroup.getChildAt(i); if (child instanceof ViewGroup) { findViewsTraversalByAutofillIds((ViewGroup) child, sourceViewIds); } addViewIfNeeded(sourceViewIds, child); } } private void addViewIfNeeded(IntArray sourceViewIds, View view) { final AutofillId autofillId = view.getAutofillId(); if (autofillId != null && (sourceViewIds.indexOf(autofillId.getViewId()) >= 0) && !mViews.containsKey(autofillId)) { mViews.put(autofillId, new WeakReference<>(view)); } } private void runForEachView(BiConsumer action) { synchronized (mLock) { boolean isLoggable = Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG); final ArrayMap> views = new ArrayMap<>(mViews); if (views.size() == 0) { Log.w(TAG, "No views can be excuted for runForEachView."); } mActivity.runOnUiThread(() -> { final int viewCounts = views.size(); for (int i = 0; i < viewCounts; i++) { final View view = views.valueAt(i).get(); if (isLoggable) { Log.d(TAG, "runForEachView for autofillId = " + (view != null ? view.getAutofillId() : " null")); } if (view == null || view.getViewTranslationCallback() == null) { if (isLoggable) { Log.d(TAG, "View was gone or ViewTranslationCallback for autofillId " + "= " + views.keyAt(i)); } continue; } action.accept(view, view.getViewTranslationCallback()); } }); } } private Translator createTranslatorIfNeeded( TranslationSpec sourceSpec, TranslationSpec targetSpec) { final TranslationManager tm = mContext.getSystemService(TranslationManager.class); if (tm == null) { Log.e(TAG, "Can not find TranslationManager when trying to create translator."); return null; } final TranslationContext translationContext = new TranslationContext.Builder(sourceSpec, targetSpec) .setActivityId( new ActivityId( mActivity.getTaskId(), mActivity.getShareableActivityToken())) .build(); final Translator translator = tm.createTranslator(translationContext); if (translator != null) { final Pair specs = new Pair<>(sourceSpec, targetSpec); mTranslators.put(specs, translator); } return translator; } private void destroyTranslators() { synchronized (mLock) { final int count = mTranslators.size(); for (int i = 0; i < count; i++) { Translator translator = mTranslators.valueAt(i); translator.destroy(); } mTranslators.clear(); } } /** * Returns a string representation of the state. */ public static String stateToString(@UiTranslationState int state) { switch (state) { case STATE_UI_TRANSLATION_STARTED: return "UI_TRANSLATION_STARTED"; case STATE_UI_TRANSLATION_PAUSED: return "UI_TRANSLATION_PAUSED"; case STATE_UI_TRANSLATION_RESUMED: return "UI_TRANSLATION_RESUMED"; case STATE_UI_TRANSLATION_FINISHED: return "UI_TRANSLATION_FINISHED"; default: return "Unknown state (" + state + ")"; } } /** * Returns a sanitized string representation of {@link ViewTranslationRequest}; */ private static String sanitizedViewTranslationRequest(@NonNull ViewTranslationRequest request) { StringBuilder msg = new StringBuilder("ViewTranslationRequest:{values=["); for (String key: request.getKeys()) { final TranslationRequestValue value = request.getValue(key); msg.append("{text=").append(value.getText() == null ? "null" : "string[" + value.getText().length() + "]}, "); } return msg.toString(); } /** * Returns a sanitized string representation of {@link ViewTranslationResponse}; */ private static String sanitizedViewTranslationResponse( @NonNull ViewTranslationResponse response) { StringBuilder msg = new StringBuilder("ViewTranslationResponse:{values=["); for (String key: response.getKeys()) { final TranslationResponseValue value = response.getValue(key); msg.append("{status=").append(value.getStatusCode()).append(", "); msg.append("text=").append(value.getText() == null ? "null" : "string[" + value.getText().length() + "], "); final Bundle definitions = (Bundle) value.getExtras().get(TranslationResponseValue.EXTRA_DEFINITIONS); if (definitions != null) { msg.append("definitions={"); for (String partOfSpeech : definitions.keySet()) { msg.append(partOfSpeech).append(":["); for (CharSequence definition : definitions.getCharSequenceArray(partOfSpeech)) { msg.append(definition == null ? "null, " : "string[" + definition.length() + "], "); } msg.append("], "); } msg.append("}"); } msg.append("transliteration=").append(value.getTransliteration() == null ? "null" : "string[" + value.getTransliteration().length() + "]}, "); } return msg.toString(); } }