Merge "Add internal TestViewConfiguration" into androidx-main
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/ActivityLifecycleDelegate.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/ActivityLifecycleDelegate.java
index 66bad88..0df1174 100644
--- a/car/app/app-activity/src/main/java/androidx/car/app/activity/ActivityLifecycleDelegate.java
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/ActivityLifecycleDelegate.java
@@ -32,7 +32,6 @@
* IRendererCallback}.
*/
final class ActivityLifecycleDelegate implements ActivityLifecycleCallbacks {
- public static final String TAG = "ActivityLifecycleListener";
@NonNull
private ServiceDispatcher mServiceDispatcher;
@Nullable
@@ -88,8 +87,7 @@
@Override
public void onActivityPreDestroyed(@NonNull Activity activity) {
- requireNonNull(activity);
- notifyEvent(Event.ON_DESTROY);
+ // No-op as the view model will call terminate on the service when needed.
}
@Override
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppActivity.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppActivity.java
index f87ebec..1130a27 100644
--- a/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppActivity.java
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppActivity.java
@@ -22,15 +22,10 @@
import android.annotation.SuppressLint;
import android.content.ComponentName;
-import android.content.Context;
import android.content.Intent;
-import android.content.ServiceConnection;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
-import android.content.pm.ResolveInfo;
import android.os.Bundle;
-import android.os.IBinder;
-import android.os.RemoteException;
import android.util.Log;
import android.view.View;
@@ -49,8 +44,7 @@
import androidx.car.app.serialization.BundlerException;
import androidx.car.app.utils.ThreadUtils;
import androidx.fragment.app.FragmentActivity;
-
-import java.util.List;
+import androidx.lifecycle.ViewModelProvider;
/**
* The class representing a car app activity.
@@ -96,20 +90,16 @@
public final class CarAppActivity extends FragmentActivity {
@VisibleForTesting
static final String SERVICE_METADATA_KEY = "androidx.car.app.CAR_APP_SERVICE";
- private static final String TAG = "CarAppActivity";
@SuppressLint({"ActionValue"})
@VisibleForTesting
static final String ACTION_RENDER = "android.car.template.host.RendererService";
- @Nullable
- private ComponentName mServiceComponentName;
TemplateSurfaceView mSurfaceView;
- SurfaceHolderListener mSurfaceHolderListener;
- ActivityLifecycleDelegate mActivityLifecycleDelegate;
- @Nullable
- OnBackPressedListener mOnBackPressedListener;
- ServiceDispatcher mServiceDispatcher;
+ @Nullable SurfaceHolderListener mSurfaceHolderListener;
+ @Nullable ActivityLifecycleDelegate mActivityLifecycleDelegate;
+ @Nullable OnBackPressedListener mOnBackPressedListener;
+ @Nullable CarAppViewModel mViewModel;
private int mDisplayId;
/**
@@ -121,7 +111,7 @@
Log.e(LogTags.TAG, "Service error: " + errorType, exception);
- unbindService();
+ requireNonNull(mViewModel).unbind();
ThreadUtils.runOnMain(() -> {
Log.d(LogTags.TAG, "Showing error fragment");
@@ -162,14 +152,16 @@
ThreadUtils.runOnMain(
() -> {
mSurfaceView.setOnCreateInputConnectionListener(editorInfo ->
- mServiceDispatcher.fetch(null, () ->
+ getServiceDispatcher().fetch(null, () ->
callback.onCreateInputConnection(
editorInfo)));
mOnBackPressedListener = () ->
- mServiceDispatcher.dispatch(callback::onBackPressed);
+ getServiceDispatcher().dispatch(callback::onBackPressed);
- mActivityLifecycleDelegate.registerRendererCallback(callback);
+ requireNonNull(mActivityLifecycleDelegate)
+ .registerRendererCallback(callback);
+ requireNonNull(mViewModel).setRendererCallback(callback);
});
}
@@ -177,7 +169,8 @@
public void setSurfaceListener(@NonNull ISurfaceListener listener) {
requireNonNull(listener);
ThreadUtils.runOnMain(
- () -> mSurfaceHolderListener.setSurfaceListener(listener));
+ () -> requireNonNull(mSurfaceHolderListener)
+ .setSurfaceListener(listener));
}
@Override
@@ -201,87 +194,41 @@
}
};
- /** The service connection for the renderer service. */
- private ServiceConnection mServiceConnectionImpl =
- new ServiceConnection() {
- @Override
- public void onServiceConnected(
- @NonNull ComponentName name, @NonNull IBinder service) {
- requireNonNull(name);
- requireNonNull(service);
- Log.i(LogTags.TAG, String.format("Host service %s is connected",
- name.flattenToShortString()));
- IRendererService rendererService = IRendererService.Stub.asInterface(service);
- if (rendererService == null) {
- mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
- new Exception("Failed to get IRenderService binder from host: "
- + name));
- return;
- }
-
- mServiceDispatcher.setRendererService(rendererService);
- verifyServiceVersion(rendererService);
- initializeService(rendererService);
- updateIntent(getIntent());
- }
-
- @Override
- public void onServiceDisconnected(@NonNull ComponentName name) {
- requireNonNull(name);
-
- // Connection lost, but it might reconnect.
- Log.w(LogTags.TAG, String.format("Host service %s is disconnected",
- name.flattenToShortString()));
- }
-
- @Override
- public void onBindingDied(@NonNull ComponentName name) {
- requireNonNull(name);
-
- // Connection permanently lost
- mErrorHandler.onError(ErrorHandler.ErrorType.HOST_CONNECTION_LOST,
- new Exception("Host service " + name + " is permanently disconnected"));
- }
-
- @Override
- public void onNullBinding(@NonNull ComponentName name) {
- requireNonNull(name);
-
- // Host rejected the binding.
- mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
- new Exception("Host service " + name + " rejected the binding "
- + "request"));
- }
- };
-
@SuppressWarnings("deprecation")
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
-
- mServiceDispatcher = new ServiceDispatcher(mErrorHandler);
setContentView(R.layout.activity_template);
mSurfaceView = requireViewById(R.id.template_view_surface);
- mActivityLifecycleDelegate = new ActivityLifecycleDelegate(mServiceDispatcher);
- mSurfaceHolderListener = new SurfaceHolderListener(mServiceDispatcher,
- new SurfaceWrapperProvider(mSurfaceView));
- mServiceComponentName = retrieveServiceComponentName();
- if (mServiceComponentName == null) {
- Log.e(TAG, "Unspecified service class name");
+ ComponentName serviceComponentName = retrieveServiceComponentName();
+ if (serviceComponentName == null) {
+ Log.e(LogTags.TAG, "Unspecified service class name");
finish();
return;
}
+ mDisplayId = getWindowManager().getDefaultDisplay().getDisplayId();
- registerActivityLifecycleCallbacks(mActivityLifecycleDelegate);
+ CarAppViewModelFactory factory = CarAppViewModelFactory.getInstance(getApplication(),
+ serviceComponentName);
+ mViewModel = new ViewModelProvider(this, factory).get(CarAppViewModel.class);
+ mViewModel.getErrorEvent().observe(this,
+ errorEvent -> mErrorHandler.onError(errorEvent.getErrorType(),
+ errorEvent.getException()));
+
+ mActivityLifecycleDelegate = new ActivityLifecycleDelegate(getServiceDispatcher());
+ mSurfaceHolderListener = new SurfaceHolderListener(getServiceDispatcher(),
+ new SurfaceWrapperProvider(mSurfaceView));
+
+ registerActivityLifecycleCallbacks(requireNonNull(mActivityLifecycleDelegate));
// Set the z-order to receive the UI events on the surface.
mSurfaceView.setZOrderOnTop(true);
- mSurfaceView.setServiceDispatcher(mServiceDispatcher);
+ mSurfaceView.setServiceDispatcher(getServiceDispatcher());
mSurfaceView.setErrorHandler(mErrorHandler);
mSurfaceView.getHolder().addCallback(mSurfaceHolderListener);
- mDisplayId = getWindowManager().getDefaultDisplay().getDisplayId();
- bindService();
+
+ mViewModel.bind(getIntent(), mCarActivity, mDisplayId);
}
@Override
@@ -300,13 +247,6 @@
}
}
- @Override
- protected void onDestroy() {
- if (isFinishing()) {
- unbindService();
- }
- super.onDestroy();
- }
@Override
public void onBackPressed() {
@@ -318,21 +258,7 @@
@Override
protected void onNewIntent(@NonNull Intent intent) {
super.onNewIntent(intent);
- if (!mServiceDispatcher.isBound()) {
- bindService();
- } else {
- updateIntent(intent);
- }
- }
-
- @VisibleForTesting
- ServiceConnection getServiceConnection() {
- return mServiceConnectionImpl;
- }
-
- @VisibleForTesting
- void setServiceConnection(ServiceConnection serviceConnection) {
- mServiceConnectionImpl = serviceConnection;
+ requireNonNull(mViewModel).bind(intent, mCarActivity, mDisplayId);
}
@VisibleForTesting
@@ -340,6 +266,11 @@
return mDisplayId;
}
+ @VisibleForTesting
+ ServiceDispatcher getServiceDispatcher() {
+ return requireNonNull(mViewModel).getServiceDispatcher();
+ }
+
@Nullable
private ComponentName retrieveServiceComponentName() {
ActivityInfo activityInfo = null;
@@ -348,7 +279,7 @@
getPackageManager()
.getActivityInfo(getComponentName(), PackageManager.GET_META_DATA);
} catch (NameNotFoundException e) {
- Log.e(TAG, "Unable to find component: " + getComponentName(), e);
+ Log.e(LogTags.TAG, "Unable to find component: " + getComponentName(), e);
}
if (activityInfo == null) {
@@ -358,7 +289,7 @@
String serviceName = activityInfo.metaData.getString(SERVICE_METADATA_KEY);
if (serviceName == null) {
Log.e(
- TAG,
+ LogTags.TAG,
"Unable to find required metadata tag with name "
+ SERVICE_METADATA_KEY
+ ". App manifest must include metadata tag with name "
@@ -369,113 +300,4 @@
return new ComponentName(this, serviceName);
}
-
- /** Binds to the renderer service. */
- private void bindService() {
- Intent rendererIntent = new Intent(ACTION_RENDER);
- List<ResolveInfo> resolveInfoList =
- getPackageManager()
- .queryIntentServices(rendererIntent, PackageManager.GET_META_DATA);
- if (resolveInfoList.size() == 1) {
- rendererIntent.setPackage(resolveInfoList.get(0).serviceInfo.packageName);
- if (!bindService(
- rendererIntent,
- mServiceConnectionImpl,
- Context.BIND_AUTO_CREATE | Context.BIND_INCLUDE_CAPABILITIES)) {
- mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
- new Exception("Cannot bind to the renderer host with intent: "
- + rendererIntent));
- }
- } else if (resolveInfoList.isEmpty()) {
- mErrorHandler.onError(ErrorHandler.ErrorType.HOST_NOT_FOUND, new Exception("No "
- + "handlers found for intent: " + rendererIntent));
- } else {
- StringBuilder logMessage =
- new StringBuilder("Multiple hosts found, only one is allowed");
- for (ResolveInfo resolveInfo : resolveInfoList) {
- logMessage.append(
- String.format("\nFound host %s", resolveInfo.serviceInfo.packageName));
- }
- mErrorHandler.onError(ErrorHandler.ErrorType.MULTIPLE_HOSTS,
- new Exception(logMessage.toString()));
- }
- }
-
- /**
- * Verifies that the renderer service supports the current version.
- *
- * @param rendererService the renderer service which should verify the version
- */
- void verifyServiceVersion(IRendererService rendererService) {
- // TODO(169604451) Add version support logic
- boolean isCompatible = true;
-
- if (!isCompatible) {
- mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
- new Exception("Renderer service unsupported"));
- }
- }
-
- /**
- * Initializes the {@code rendererService} for the current activity with {@code carActivity},
- * {@code serviceComponentName} and {@code displayId}.
- *
- * @param rendererService the renderer service that needs to be initialized
- */
- void initializeService(@NonNull IRendererService rendererService) {
- requireNonNull(rendererService);
- requireNonNull(mServiceComponentName);
- ComponentName serviceComponentName = mServiceComponentName;
- Boolean success = mServiceDispatcher.fetch(false,
- () -> rendererService.initialize(mCarActivity,
- serviceComponentName, mDisplayId));
- if (success == null || !success) {
- mErrorHandler.onError(ErrorHandler.ErrorType.HOST_ERROR,
- new Exception("Cannot create renderer for" + mServiceComponentName));
- }
- }
-
- /** Closes the connection to the connected {@code rendererService} if any. */
- void unbindService() {
- // Remove the renderer callback since there is no need to communicate the state with
- // the host.
- mActivityLifecycleDelegate.registerRendererCallback(null);
- // Stop sending SurfaceView updates
- mSurfaceView.getHolder().removeCallback(mSurfaceHolderListener);
- // If host has already disconnected, there is no need for an unbind.
- IRendererService rendererService = mServiceDispatcher.getRendererService();
- if (rendererService == null) {
- return;
- }
- try {
- rendererService.terminate(requireNonNull(mServiceComponentName));
- } catch (RemoteException e) {
- // We are already unbinding (maybe because the host has already cut the connection)
- // Let's not log more errors unnecessarily.
- }
-
- Log.i(LogTags.TAG, "Unbinding from " + mServiceComponentName);
- unbindService(mServiceConnectionImpl);
- mServiceDispatcher.setRendererService(null);
- }
-
- /**
- * Updates the activity intent for the {@code rendererService}.
- */
- void updateIntent(Intent intent) {
- requireNonNull(mServiceComponentName);
- IRendererService service = mServiceDispatcher.getRendererService();
- if (service == null) {
- mErrorHandler.onError(ErrorHandler.ErrorType.CLIENT_SIDE_ERROR,
- new Exception("Service dispatcher is not connected"));
- return;
- }
- ComponentName serviceComponentName = mServiceComponentName;
- Boolean success = mServiceDispatcher.fetch(false, () ->
- service.onNewIntent(intent, serviceComponentName, mDisplayId));
- if (success == null || !success) {
- mErrorHandler.onError(ErrorHandler.ErrorType.HOST_ERROR, new Exception("Renderer "
- + "cannot handle the intent: " + intent));
- }
- }
}
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModel.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModel.java
new file mode 100644
index 0000000..aa48ca9
--- /dev/null
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModel.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 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 androidx.car.app.activity;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.activity.renderer.ICarAppActivity;
+import androidx.car.app.activity.renderer.IRendererCallback;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.MutableLiveData;
+
+/**
+ * The view model to keep track of the CarAppActivity data.
+ *
+ * This main role of this class is to extent the life of a service connection beyond the regular
+ * lifecycle of an activity. This is done by making sure the unbind happens when the view model
+ * clears instead of when the activity calls onDestroy.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY)
+public class CarAppViewModel extends AndroidViewModel implements ErrorHandler {
+ /** Holds the information about an error event. */
+ public static class ErrorEvent {
+ private final ErrorType mErrorType;
+ private final Throwable mException;
+
+ public ErrorEvent(@NonNull ErrorType errorType, @NonNull Throwable exception) {
+ mErrorType = errorType;
+ mException = exception;
+ }
+
+ /** Returns the type of error. */
+ @NonNull ErrorType getErrorType() {
+ return mErrorType;
+ }
+
+ /** Returns the exception associated with this error event. */
+ @NonNull Throwable getException() {
+ return mException;
+ }
+ }
+
+ private final MutableLiveData<ErrorEvent> mErrorEvent = new MutableLiveData<>();
+ private ServiceConnectionManager mServiceConnectionManager;
+ @Nullable private IRendererCallback mIRendererCallback;
+
+ public CarAppViewModel(@NonNull Application application, @NonNull ComponentName componentName) {
+ super(application);
+
+ mServiceConnectionManager = new ServiceConnectionManager(application, componentName, this);
+ }
+
+ @VisibleForTesting
+ @NonNull ServiceConnectionManager getServiceConnectionManager() {
+ return mServiceConnectionManager;
+ }
+
+ @VisibleForTesting
+ void setServiceConnectionManager(ServiceConnectionManager serviceConnectionManager) {
+ mServiceConnectionManager = serviceConnectionManager;
+ }
+
+ @NonNull ServiceDispatcher getServiceDispatcher() {
+ return mServiceConnectionManager.getServiceDispatcher();
+ }
+
+ /** Updates the rendeer callback. */
+ void setRendererCallback(@NonNull IRendererCallback rendererCallback) {
+ mIRendererCallback = rendererCallback;
+ }
+
+ /**
+ * Binds to the renderer service and initializes the service if not bound already.
+ *
+ * Initializes the renderer service with given properties if already bound to the renderer
+ * service.
+ */
+ void bind(@NonNull Intent intent, @NonNull ICarAppActivity iCarAppActivity,
+ int displayId) {
+ mServiceConnectionManager.bind(intent, iCarAppActivity, displayId);
+ }
+
+ /** Closes the connection to the renderer service if any. */
+ void unbind() {
+ mServiceConnectionManager.unbind();
+ }
+
+ @NonNull
+ MutableLiveData<ErrorEvent> getErrorEvent() {
+ return mErrorEvent;
+ }
+
+ @Override
+ protected void onCleared() {
+ super.onCleared();
+ if (mIRendererCallback != null) {
+ mServiceConnectionManager.getServiceDispatcher()
+ .dispatch(mIRendererCallback::onDestroyed);
+ }
+ mServiceConnectionManager.unbind();
+ }
+
+ @Override
+ public void onError(@NonNull ErrorHandler.ErrorType errorType, @NonNull Throwable exception) {
+ mErrorEvent.setValue(new ErrorEvent(errorType, exception));
+ }
+}
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModelFactory.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModelFactory.java
new file mode 100644
index 0000000..3c9627d
--- /dev/null
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModelFactory.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 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 androidx.car.app.activity;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import android.app.Application;
+import android.content.ComponentName;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.lifecycle.ViewModel;
+import androidx.lifecycle.ViewModelProvider;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A factory to provide a unique {@link CarAppViewModel} for each given {@link ComponentName}.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY)
+class CarAppViewModelFactory implements ViewModelProvider.Factory {
+ private static final Map<ComponentName, CarAppViewModelFactory> sInstances = new HashMap<>();
+
+ Application mApplication;
+ ComponentName mComponentName;
+
+ private CarAppViewModelFactory(@NonNull ComponentName componentName,
+ @NonNull Application application) {
+ mComponentName = componentName;
+ mApplication = application;
+ }
+
+ /**
+ * Retrieve a singleton instance of CarAppViewModelFactory for the given key.
+ *
+ * @return A valid {@link CarAppViewModelFactory}
+ */
+ @NonNull
+ static CarAppViewModelFactory getInstance(Application application,
+ ComponentName componentName) {
+ CarAppViewModelFactory instance = sInstances.get(componentName);
+ if (instance == null) {
+ instance = new CarAppViewModelFactory(componentName, application);
+ sInstances.put(componentName, instance);
+ }
+ return instance;
+ }
+
+ @SuppressWarnings("unchecked")
+ @NonNull
+ @Override
+ public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
+ return (T) new CarAppViewModel(mApplication, mComponentName);
+ }
+}
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceConnectionManager.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceConnectionManager.java
new file mode 100644
index 0000000..93f9b5d
--- /dev/null
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceConnectionManager.java
@@ -0,0 +1,267 @@
+/*
+ * Copyright 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 androidx.car.app.activity;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.SuppressLint;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.activity.renderer.ICarAppActivity;
+import androidx.car.app.activity.renderer.IRendererService;
+
+import java.util.List;
+
+/**
+ * Manages the renderer service connection state.
+ *
+ * This class handles binding and unbinding to the renderer service and make sure the renderer
+ * service gets initialized and terminated properly.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY)
+public class ServiceConnectionManager {
+ @SuppressLint({"ActionValue"})
+ @VisibleForTesting
+ static final String ACTION_RENDER = "android.car.template.host.RendererService";
+
+ final ErrorHandler mErrorHandler;
+ private final ComponentName mServiceComponentName;
+ private final Context mContext;
+ private final ServiceDispatcher mServiceDispatcher;
+ private int mDisplayId;
+ @Nullable private Intent mIntent;
+ @Nullable private ICarAppActivity mICarAppActivity;
+
+ @Nullable IRendererService mRendererService;
+
+ public ServiceConnectionManager(@NonNull Context context,
+ @NonNull ComponentName serviceComponentName, @NonNull ErrorHandler errorHandler) {
+ mContext = context;
+ mErrorHandler = errorHandler;
+ mServiceComponentName = serviceComponentName;
+ mServiceDispatcher = new ServiceDispatcher(mErrorHandler, this::isBound);
+ }
+
+ /**
+ * Returns a {@link ServiceDispatcher} that can be used to communicate with the renderer
+ * service.
+ */
+ @NonNull ServiceDispatcher getServiceDispatcher() {
+ return mServiceDispatcher;
+ }
+
+ @VisibleForTesting
+ ComponentName getServiceComponentName() {
+ return mServiceComponentName;
+ }
+
+ @VisibleForTesting
+ ErrorHandler getErrorHandler() {
+ return mErrorHandler;
+ }
+
+ @VisibleForTesting
+ ServiceConnection getServiceConnection() {
+ return mServiceConnectionImpl;
+ }
+
+ @VisibleForTesting
+ void setServiceConnection(ServiceConnection serviceConnection) {
+ mServiceConnectionImpl = serviceConnection;
+ }
+
+ @VisibleForTesting
+ void setRendererService(@Nullable IRendererService rendererService) {
+ mRendererService = rendererService;
+ }
+
+ /** Returns true if the service is currently bound and able to receive messages */
+ boolean isBound() {
+ return mRendererService != null;
+ }
+
+ /** The service connection for the renderer service. */
+ private ServiceConnection mServiceConnectionImpl =
+ new ServiceConnection() {
+ @Override
+ public void onServiceConnected(
+ @NonNull ComponentName name, @NonNull IBinder service) {
+ requireNonNull(name);
+ requireNonNull(service);
+ Log.i(LogTags.TAG, String.format("Host service %s is connected",
+ name.flattenToShortString()));
+ IRendererService rendererService = IRendererService.Stub.asInterface(service);
+ if (rendererService == null) {
+ mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
+ new Exception("Failed to get IRenderService binder from host: "
+ + name));
+ return;
+ }
+
+ mRendererService = rendererService;
+ initializeService();
+ }
+
+ @Override
+ public void onServiceDisconnected(@NonNull ComponentName name) {
+ requireNonNull(name);
+
+ // Connection lost, but it might reconnect.
+ Log.w(LogTags.TAG, String.format("Host service %s is disconnected",
+ name.flattenToShortString()));
+ }
+
+ @Override
+ public void onBindingDied(@NonNull ComponentName name) {
+ requireNonNull(name);
+
+ // Connection permanently lost
+ mErrorHandler.onError(ErrorHandler.ErrorType.HOST_CONNECTION_LOST,
+ new Exception("Host service " + name + " is permanently disconnected"));
+ }
+
+ @Override
+ public void onNullBinding(@NonNull ComponentName name) {
+ requireNonNull(name);
+
+ // Host rejected the binding.
+ mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
+ new Exception("Host service " + name + " rejected the binding "
+ + "request"));
+ }
+ };
+
+ /**
+ * Binds to the renderer service and initializes the service if not bound already.
+ *
+ * Initializes the renderer service with given properties if already bound to the renderer
+ * service.
+ */
+ void bind(@NonNull Intent intent, @NonNull ICarAppActivity iCarAppActivity, int displayId) {
+ mIntent = requireNonNull(intent);
+ mICarAppActivity = requireNonNull(iCarAppActivity);
+ mDisplayId = displayId;
+
+ if (isBound()) {
+ initializeService();
+ return;
+ }
+
+ Intent rendererIntent = new Intent(ACTION_RENDER);
+ List<ResolveInfo> resolveInfoList =
+ mContext.getPackageManager()
+ .queryIntentServices(rendererIntent, PackageManager.GET_META_DATA);
+ if (resolveInfoList.size() == 1) {
+ rendererIntent.setPackage(resolveInfoList.get(0).serviceInfo.packageName);
+ if (!mContext.bindService(
+ rendererIntent,
+ mServiceConnectionImpl,
+ Context.BIND_AUTO_CREATE | Context.BIND_INCLUDE_CAPABILITIES)) {
+ mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
+ new Exception("Cannot bind to the renderer host with intent: "
+ + rendererIntent));
+ }
+ } else if (resolveInfoList.isEmpty()) {
+ mErrorHandler.onError(ErrorHandler.ErrorType.HOST_NOT_FOUND, new Exception("No "
+ + "handlers found for intent: " + rendererIntent));
+ } else {
+ StringBuilder logMessage =
+ new StringBuilder("Multiple hosts found, only one is allowed");
+ for (ResolveInfo resolveInfo : resolveInfoList) {
+ logMessage.append(
+ String.format("\nFound host %s", resolveInfo.serviceInfo.packageName));
+ }
+ mErrorHandler.onError(ErrorHandler.ErrorType.MULTIPLE_HOSTS,
+ new Exception(logMessage.toString()));
+ }
+ }
+
+ /** Closes the connection to the connected {@code rendererService} if any. */
+ void unbind() {
+ if (mRendererService == null) {
+ return;
+ }
+ try {
+ mRendererService.terminate(requireNonNull(mServiceComponentName));
+ } catch (RemoteException e) {
+ // We are already unbinding (maybe because the host has already cut the connection)
+ // Let's not log more errors unnecessarily.
+ }
+
+ Log.i(LogTags.TAG, "Unbinding from " + mServiceComponentName);
+ mContext.unbindService(mServiceConnectionImpl);
+ mRendererService = null;
+ }
+
+ /**
+ * Initializes the {@code rendererService} for the current {@code carIAppActivity},
+ * {@code serviceComponentName} and {@code displayId}.
+ */
+ void initializeService() {
+ ICarAppActivity carAppActivity = requireNonNull(mICarAppActivity);
+ IRendererService rendererService = requireNonNull(mRendererService);
+ ComponentName serviceComponentName = requireNonNull(mServiceComponentName);
+
+ Boolean success = mServiceDispatcher.fetch(false,
+ () -> rendererService.initialize(carAppActivity,
+ serviceComponentName, mDisplayId));
+ if (success == null || !success) {
+ mErrorHandler.onError(ErrorHandler.ErrorType.HOST_ERROR,
+ new Exception("Cannot create renderer for" + serviceComponentName));
+ return;
+ }
+ updateIntent();
+ }
+
+ /**
+ * Updates the activity intent for the connected {@code rendererService}.
+ */
+ private void updateIntent() {
+ ComponentName serviceComponentName = requireNonNull(mServiceComponentName);
+ Intent intent = requireNonNull(mIntent);
+
+ IRendererService service = mRendererService;
+ if (service == null) {
+ mErrorHandler.onError(ErrorHandler.ErrorType.CLIENT_SIDE_ERROR,
+ new Exception("Service dispatcher is not connected"));
+ return;
+ }
+
+ Boolean success = mServiceDispatcher.fetch(false, () ->
+ service.onNewIntent(intent, serviceComponentName, mDisplayId));
+ if (success == null || !success) {
+ mErrorHandler.onError(ErrorHandler.ErrorType.HOST_ERROR, new Exception("Renderer "
+ + "cannot handle the intent: " + intent));
+ }
+ }
+}
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceDispatcher.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceDispatcher.java
index a77a3e5..83c575f 100644
--- a/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceDispatcher.java
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceDispatcher.java
@@ -23,6 +23,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
import androidx.car.app.activity.renderer.IRendererService;
import androidx.car.app.serialization.BundlerException;
@@ -33,9 +34,15 @@
*/
@RestrictTo(LIBRARY)
public class ServiceDispatcher {
+
+ /** An interface for monitoring the binding state of a service connection. */
+ public interface OnBindingListener {
+ /** Returns true if the service connection is bound. */
+ boolean isBound();
+ }
+
private final ErrorHandler mErrorHandler;
- @Nullable
- private IRendererService mRendererService;
+ private OnBindingListener mOnBindingListener;
/** A one way call to the service */
public interface OneWayCall {
@@ -56,28 +63,15 @@
T invoke() throws RemoteException, BundlerException;
}
- public ServiceDispatcher(@NonNull ErrorHandler errorHandler) {
+ public ServiceDispatcher(@NonNull ErrorHandler errorHandler,
+ @NonNull OnBindingListener onBindingListener) {
mErrorHandler = errorHandler;
+ mOnBindingListener = onBindingListener;
}
- /**
- * Updates the bound service reference
- *
- * @param rendererService bound service or {@code null} if the service is not bound.
- */
- public void setRendererService(@Nullable IRendererService rendererService) {
- mRendererService = rendererService;
- }
-
- /** Returns the bound service, or null if the service is not bound */
- @Nullable
- public IRendererService getRendererService() {
- return mRendererService;
- }
-
- /** Returns true if the service is currently bound and able to receive messages */
- public boolean isBound() {
- return mRendererService != null;
+ @VisibleForTesting
+ public void setOnBindingListener(@NonNull OnBindingListener onBindingListener) {
+ mOnBindingListener = onBindingListener;
}
/** Dispatches the given {@link OneWayCall}. This is a non-blocking call. */
@@ -99,7 +93,7 @@
// TODO(b/184697399): Remove two-way calls as these are blocking.
@Nullable
public <T> T fetch(@Nullable T fallbackValue, @NonNull ReturnCall<T> call) {
- if (mRendererService == null) {
+ if (!mOnBindingListener.isBound()) {
// Avoid dispatching messages if we are not bound to the service
return fallbackValue;
}
diff --git a/car/app/app-activity/src/main/res/layout/activity_template.xml b/car/app/app-activity/src/main/res/layout/activity_template.xml
index 09c432f..7054403 100644
--- a/car/app/app-activity/src/main/res/layout/activity_template.xml
+++ b/car/app/app-activity/src/main/res/layout/activity_template.xml
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/fragment_container">
<androidx.car.app.activity.renderer.surface.TemplateSurfaceView
@@ -11,6 +10,7 @@
android:focusableInTouchMode="true" />
<LinearLayout
+ android:background="#000000"
android:id="@+id/error_message_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppActivityTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppActivityTest.java
index 8cb2e32..7ffef3b 100644
--- a/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppActivityTest.java
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppActivityTest.java
@@ -239,8 +239,11 @@
CarAppActivity.class)) {
scenario.onActivity(activity -> {
try {
- ServiceConnection serviceConnection = spy(activity.getServiceConnection());
- activity.setServiceConnection(serviceConnection);
+ ServiceConnectionManager serviceConnectionManager =
+ activity.mViewModel.getServiceConnectionManager();
+ ServiceConnection serviceConnection =
+ spy(serviceConnectionManager.getServiceConnection());
+ serviceConnectionManager.setServiceConnection(serviceConnection);
// Destroy activity to force unbind.
scenario.moveToState(Lifecycle.State.DESTROYED);
@@ -251,7 +254,7 @@
// Verify service connection is closed.
verify(serviceConnection, times(1)).onServiceDisconnected(
mRendererComponent);
- assertThat(activity.mServiceDispatcher.isBound()).isFalse();
+ assertThat(serviceConnectionManager.isBound()).isFalse();
} catch (RemoteException e) {
fail(Log.getStackTraceString(e));
}
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelFactoryTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelFactoryTest.java
new file mode 100644
index 0000000..7650a2d
--- /dev/null
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelFactoryTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 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 androidx.car.app.activity;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import android.content.ComponentName;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link CarAppViewModelFactory} */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class CarAppViewModelFactoryTest {
+ private static final ComponentName TEST_COMPONENT_NAME_1 = new ComponentName(
+ ApplicationProvider.getApplicationContext(), "Class1");
+ private static final ComponentName TEST_COMPONENT_NAME_2 = new ComponentName(
+ ApplicationProvider.getApplicationContext(), "Class2");
+
+ private final Application mApplication = ApplicationProvider.getApplicationContext();
+
+ @Test
+ public void getInstance_sameKey_returnsSame() {
+ CarAppViewModelFactory factory1 = CarAppViewModelFactory.getInstance(mApplication,
+ TEST_COMPONENT_NAME_1);
+
+ CarAppViewModelFactory factory2 = CarAppViewModelFactory.getInstance(mApplication,
+ TEST_COMPONENT_NAME_1);
+
+ assertThat(factory1).isEqualTo(factory2);
+ }
+
+ @Test
+ public void getInstance_differentKeys_returnsDifferent() {
+ CarAppViewModelFactory factory1 = CarAppViewModelFactory.getInstance(mApplication,
+ TEST_COMPONENT_NAME_1);
+
+ CarAppViewModelFactory factory2 = CarAppViewModelFactory.getInstance(mApplication,
+ TEST_COMPONENT_NAME_2);
+
+ assertThat(factory1).isNotEqualTo(factory2);
+ }
+
+ @Test
+ public void create_correctComponentName() {
+ CarAppViewModelFactory factory = CarAppViewModelFactory.getInstance(mApplication,
+ TEST_COMPONENT_NAME_1);
+
+ CarAppViewModel viewModel = factory.create(CarAppViewModel.class);
+
+ assertThat(viewModel).isNotNull();
+ assertThat(viewModel.getServiceConnectionManager().getServiceComponentName())
+ .isEqualTo(TEST_COMPONENT_NAME_1);
+ }
+}
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java
new file mode 100644
index 0000000..9eda009
--- /dev/null
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 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 androidx.car.app.activity;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Intent;
+
+import androidx.car.app.activity.renderer.ICarAppActivity;
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link CarAppViewModel} */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class CarAppViewModelTest {
+ private static final ComponentName TEST_COMPONENT_NAME = new ComponentName(
+ ApplicationProvider.getApplicationContext(), "Class1");
+ private static final int TEST_DISPLAY_ID = 123;
+ private static final Intent TEST_INTENT = new Intent("TestAction");
+
+ private final Application mApplication = ApplicationProvider.getApplicationContext();
+ private CarAppViewModel mCarAppViewModel;
+ private ICarAppActivity mICarAppActivity;
+
+ @Before
+ public void setUp() {
+ mCarAppViewModel = new CarAppViewModel(mApplication, TEST_COMPONENT_NAME);
+ mICarAppActivity = mock(ICarAppActivity.class);
+ }
+
+ @Test
+ public void testSetup() {
+ assertThat(mCarAppViewModel.getServiceConnectionManager()).isNotNull();
+ assertThat(mCarAppViewModel.getServiceConnectionManager().getServiceComponentName())
+ .isEqualTo(TEST_COMPONENT_NAME);
+ assertThat(mCarAppViewModel.getServiceDispatcher()).isNotNull();
+ }
+
+ @Test
+ public void testBind_serviceConnectionManager_invoke() {
+ ServiceConnectionManager serviceConnectionManager = mock(ServiceConnectionManager.class);
+ mCarAppViewModel.setServiceConnectionManager(serviceConnectionManager);
+ mCarAppViewModel.bind(TEST_INTENT, mICarAppActivity, TEST_DISPLAY_ID);
+
+ verify(serviceConnectionManager).bind(TEST_INTENT, mICarAppActivity, TEST_DISPLAY_ID);
+ }
+
+ @Test
+ public void testUnbind_serviceConnectionManager_invoke() {
+ ServiceConnectionManager serviceConnectionManager = mock(ServiceConnectionManager.class);
+ mCarAppViewModel.setServiceConnectionManager(serviceConnectionManager);
+ mCarAppViewModel.unbind();
+
+ verify(serviceConnectionManager).unbind();
+ }
+
+ @Test
+ public void testErrorHandler_capturesErrorEvent() {
+ ErrorHandler.ErrorType errorType = ErrorHandler.ErrorType.HOST_ERROR;
+ Throwable exception = new IllegalStateException();
+
+ ErrorHandler errorHandler =
+ mCarAppViewModel.getServiceConnectionManager().getErrorHandler();
+ errorHandler.onError(errorType, exception);
+
+ CarAppViewModel.ErrorEvent errorEvent = mCarAppViewModel.getErrorEvent().getValue();
+ assertThat(errorEvent).isNotNull();
+ assertThat(errorEvent.getErrorType()).isEqualTo(errorType);
+ assertThat(errorEvent.getException()).isEqualTo(exception);
+ }
+}
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceConnectionManagerTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceConnectionManagerTest.java
new file mode 100644
index 0000000..82bc8c1
--- /dev/null
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceConnectionManagerTest.java
@@ -0,0 +1,289 @@
+/*
+ * Copyright 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 androidx.car.app.activity;
+
+import static android.os.Looper.getMainLooper;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.os.RemoteException;
+import android.util.Log;
+
+import androidx.car.app.activity.renderer.ICarAppActivity;
+import androidx.car.app.activity.renderer.IRendererService;
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.ShadowPackageManager;
+
+/** Tests for {@link ServiceConnectionManager}. */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class ServiceConnectionManagerTest {
+
+ private static final int TEST_DISPLAY_ID = 123;
+ private static final Intent TEST_INTENT = new Intent("Test");
+
+ private final ComponentName mRendererComponent = new ComponentName(
+ ApplicationProvider.getApplicationContext(), getClass().getName());
+
+ private final String mFakeCarAppServiceClass = "com.fake.FakeCarAppService";
+ private final ComponentName mFakeCarAppServiceComponent = new ComponentName(
+ ApplicationProvider.getApplicationContext(), mFakeCarAppServiceClass);
+ private final IRendererService mRenderService = mock(IRendererService.class);
+ private final RenderServiceDelegate mRenderServiceDelegate =
+ new RenderServiceDelegate(mRenderService);
+
+ private final ErrorHandler mErrorHandler = mock(ErrorHandler.class);
+ private final ServiceConnectionManager mServiceConnectionManager =
+ new ServiceConnectionManager(ApplicationProvider.getApplicationContext(),
+ mFakeCarAppServiceComponent, mErrorHandler);
+
+ private void setupCarAppActivityForTesting() {
+ try {
+ Application app = ApplicationProvider.getApplicationContext();
+
+ PackageManager packageManager = app.getPackageManager();
+ ShadowPackageManager spm = shadowOf(packageManager);
+
+ // Register fake renderer service which will be simulated by {@code mRenderService}.
+ spm.addServiceIfNotPresent(mRendererComponent);
+ spm.addIntentFilterForService(mRendererComponent,
+ new IntentFilter(CarAppActivity.ACTION_RENDER));
+
+ when(mRenderService.initialize(any(ICarAppActivity.class),
+ any(ComponentName.class),
+ anyInt())).thenReturn(true);
+ when(mRenderService.onNewIntent(any(Intent.class), any(ComponentName.class),
+ anyInt())).thenReturn(true);
+
+ ShadowApplication sa = shadowOf(app);
+ sa.setComponentNameAndServiceForBindService(mRendererComponent, mRenderServiceDelegate);
+ } catch (Exception e) {
+ fail(Log.getStackTraceString(e));
+ }
+ }
+
+ @Test
+ public void testIsBound_serviceNotNull_returnsTrue() {
+ mServiceConnectionManager.setRendererService(mock(IRendererService.class));
+ assertThat(mServiceConnectionManager.isBound()).isTrue();
+ }
+
+ @Test
+ public void testIsBound_serviceIsNull_returnsFalse() {
+ mServiceConnectionManager.setRendererService(null);
+ assertThat(mServiceConnectionManager.isBound()).isFalse();
+ }
+
+ @Test
+ public void testBind_unbound_bindsToRenderer() {
+ setupCarAppActivityForTesting();
+ ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+
+ mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+ shadowOf(getMainLooper()).idle();
+ try {
+ verify(mErrorHandler, never()).onError(any(), any());
+ verify(mRenderService).initialize(iCarAppActivity, mFakeCarAppServiceComponent,
+ TEST_DISPLAY_ID);
+ verify(mRenderService).onNewIntent(TEST_INTENT, mFakeCarAppServiceComponent,
+ TEST_DISPLAY_ID);
+ } catch (RemoteException e) {
+ fail(Log.getStackTraceString(e));
+ }
+ assertThat(mServiceConnectionManager.isBound()).isTrue();
+ }
+
+ @Test
+ public void testBind_bound_doesNotRebound() {
+ setupCarAppActivityForTesting();
+ ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+
+ IRendererService renderService = createMockRendererService();
+ mServiceConnectionManager.setRendererService(renderService);
+ mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+ shadowOf(getMainLooper()).idle();
+ try {
+ verify(mErrorHandler, never()).onError(any(), any());
+ verify(mRenderService, never()).initialize(iCarAppActivity, mFakeCarAppServiceComponent,
+ TEST_DISPLAY_ID);
+ verify(mRenderService, never()).onNewIntent(TEST_INTENT, mFakeCarAppServiceComponent,
+ TEST_DISPLAY_ID);
+ } catch (RemoteException e) {
+ fail(Log.getStackTraceString(e));
+ }
+ assertThat(mServiceConnectionManager.isBound()).isTrue();
+ }
+
+ @Test
+ public void testBind_bound_initializes() {
+ setupCarAppActivityForTesting();
+ ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+
+ IRendererService renderService = createMockRendererService();
+
+ mServiceConnectionManager.setRendererService(renderService);
+ mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+ shadowOf(getMainLooper()).idle();
+
+ try {
+ verify(mErrorHandler, never()).onError(any(), any());
+ verify(renderService).initialize(iCarAppActivity, mFakeCarAppServiceComponent,
+ TEST_DISPLAY_ID);
+ verify(renderService).onNewIntent(TEST_INTENT, mFakeCarAppServiceComponent,
+ TEST_DISPLAY_ID);
+ } catch (RemoteException e) {
+ fail(Log.getStackTraceString(e));
+ }
+ assertThat(mServiceConnectionManager.isBound()).isTrue();
+ }
+
+ @Test
+ public void testBind_bound_noRebound() {
+ setupCarAppActivityForTesting();
+ ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+ IRendererService renderService = createMockRendererService();
+
+ mServiceConnectionManager.setRendererService(renderService);
+ mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+ shadowOf(getMainLooper()).idle();
+ try {
+ verify(mErrorHandler, never()).onError(any(), any());
+ verify(mRenderService, never()).initialize(iCarAppActivity, mFakeCarAppServiceComponent,
+ TEST_DISPLAY_ID);
+ verify(mRenderService, never()).onNewIntent(TEST_INTENT, mFakeCarAppServiceComponent,
+ TEST_DISPLAY_ID);
+ } catch (RemoteException e) {
+ fail(Log.getStackTraceString(e));
+ }
+ assertThat(mServiceConnectionManager.isBound()).isTrue();
+ }
+
+ @Test
+ public void testBind_unbound_failure() {
+ setupCarAppActivityForTesting();
+ ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+
+ try {
+ when(mRenderService.initialize(any(ICarAppActivity.class),
+ any(ComponentName.class),
+ anyInt())).thenReturn(false);
+ } catch (RemoteException e) {
+ fail(Log.getStackTraceString(e));
+ }
+
+ mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+ shadowOf(getMainLooper()).idle();
+
+ verify(mErrorHandler).onError(eq(ErrorHandler.ErrorType.HOST_ERROR), any());
+ }
+
+ @Test
+ public void testUnBind_bound_terminate() {
+ setupCarAppActivityForTesting();
+
+ IRendererService renderService = mock(IRendererService.class);
+ mServiceConnectionManager.setRendererService(renderService);
+ mServiceConnectionManager.unbind();
+ shadowOf(getMainLooper()).idle();
+
+ try {
+ verify(renderService).terminate(mFakeCarAppServiceComponent);
+ } catch (RemoteException e) {
+ fail(Log.getStackTraceString(e));
+ }
+
+ assertThat(mServiceConnectionManager.isBound()).isFalse();
+ }
+
+ @Test
+ public void testUnBind_unbound_doNothing() {
+ setupCarAppActivityForTesting();
+
+ mServiceConnectionManager.unbind();
+ shadowOf(getMainLooper()).idle();
+
+ try {
+ verify(mRenderService, never()).terminate(mFakeCarAppServiceComponent);
+ } catch (RemoteException e) {
+ fail(Log.getStackTraceString(e));
+ }
+
+ assertThat(mServiceConnectionManager.isBound()).isFalse();
+ }
+
+ // Use delegate to forward events to a mock. Mockito interceptor is not maintained on
+ // top-level IBinder after call to IRenderService.Stub.asInterface() in CarAppActivity.
+ private static class RenderServiceDelegate extends IRendererService.Stub {
+ private final IRendererService mService;
+
+ RenderServiceDelegate(IRendererService service) {
+ mService = service;
+ }
+
+ @Override
+ public boolean initialize(ICarAppActivity carActivity, ComponentName serviceName,
+ int displayId) throws RemoteException {
+ return mService.initialize(carActivity, serviceName, displayId);
+ }
+
+ @Override
+ public boolean onNewIntent(Intent intent, ComponentName serviceName, int displayId)
+ throws RemoteException {
+ return mService.onNewIntent(intent, serviceName, displayId);
+ }
+
+ @Override
+ public void terminate(ComponentName serviceName) throws RemoteException {
+ mService.terminate(serviceName);
+ }
+ }
+
+ private IRendererService createMockRendererService() {
+ IRendererService renderService = mock(IRendererService.class);
+ try {
+ when(renderService.initialize(any(ICarAppActivity.class),
+ any(ComponentName.class),
+ anyInt())).thenReturn(true);
+ when(renderService.onNewIntent(any(Intent.class), any(ComponentName.class),
+ anyInt())).thenReturn(true);
+ } catch (RemoteException e) {
+ fail(Log.getStackTraceString(e));
+ }
+ return renderService;
+ }
+}
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceDispatcherTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceDispatcherTest.java
index bbb8137..ea22617 100644
--- a/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceDispatcherTest.java
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceDispatcherTest.java
@@ -48,19 +48,7 @@
@Before
public void setup() {
mErrorHandler = mock(ErrorHandler.class);
- mServiceDispatcher = new ServiceDispatcher(mErrorHandler);
- }
-
- @Test
- public void isBound_serviceNotNull_returnsTrue() {
- mServiceDispatcher.setRendererService(mock(IRendererService.class));
- assertThat(mServiceDispatcher.isBound()).isTrue();
- }
-
- @Test
- public void isBound_serviceIsNull_returnsFalse() {
- mServiceDispatcher.setRendererService(null);
- assertThat(mServiceDispatcher.isBound()).isFalse();
+ mServiceDispatcher = new ServiceDispatcher(mErrorHandler, () -> false);
}
@Test
@@ -73,7 +61,7 @@
ServiceDispatcher.OneWayCall call = () -> rendererService.onNewIntent(intent,
componentName, 0);
- mServiceDispatcher.setRendererService(rendererService);
+ mServiceDispatcher.setOnBindingListener(() -> true);
mServiceDispatcher.dispatch(call);
verify(rendererService, times(1)).onNewIntent(intent, componentName, 0);
@@ -83,7 +71,7 @@
public void dispatch_serviceNotBound_notInvoked() throws BundlerException, RemoteException {
ServiceDispatcher.OneWayCall call = mock(ServiceDispatcher.OneWayCall.class);
- mServiceDispatcher.setRendererService(null);
+ mServiceDispatcher.setOnBindingListener(() -> false);
mServiceDispatcher.dispatch(call);
verify(call, never()).invoke();
@@ -95,7 +83,7 @@
throw new RemoteException();
};
- mServiceDispatcher.setRendererService(mock(IRendererService.class));
+ mServiceDispatcher.setOnBindingListener(() -> true);
mServiceDispatcher.dispatch(call);
verify(mErrorHandler, times(1))
@@ -104,10 +92,9 @@
@Test
public void fetch_serviceBound_valueReturned() {
- IRendererService rendererService = mock(IRendererService.class);
ServiceDispatcher.ReturnCall<Integer> call = () -> 123;
- mServiceDispatcher.setRendererService(rendererService);
+ mServiceDispatcher.setOnBindingListener(() -> true);
Integer result = mServiceDispatcher.fetch(234, call);
assertThat(result).isEqualTo(123);
@@ -119,7 +106,7 @@
throws BundlerException, RemoteException {
ServiceDispatcher.ReturnCall<Integer> call = mock(ServiceDispatcher.ReturnCall.class);
- mServiceDispatcher.setRendererService(null);
+ mServiceDispatcher.setOnBindingListener(() -> false);
Integer result = mServiceDispatcher.fetch(234, call);
verify(call, never()).invoke();
@@ -132,7 +119,7 @@
throw new RemoteException();
};
- mServiceDispatcher.setRendererService(mock(IRendererService.class));
+ mServiceDispatcher.setOnBindingListener(() -> true);
Integer result = mServiceDispatcher.fetch(234, call);
verify(mErrorHandler, times(1))
diff --git a/compose/ui/ui-text/src/androidAndroidTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphTest.kt b/compose/ui/ui-text/src/androidAndroidTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphTest.kt
index fd62ae0..32d6216 100644
--- a/compose/ui/ui-text/src/androidAndroidTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphTest.kt
+++ b/compose/ui/ui-text/src/androidAndroidTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphTest.kt
@@ -9,8 +9,6 @@
import android.text.style.LocaleSpan
import android.text.style.RelativeSizeSpan
import android.text.style.ScaleXSpan
-import android.text.style.StrikethroughSpan
-import android.text.style.UnderlineSpan
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
@@ -29,6 +27,7 @@
import androidx.compose.ui.text.android.style.LetterSpacingSpanPx
import androidx.compose.ui.text.android.style.ShadowSpan
import androidx.compose.ui.text.android.style.SkewXSpan
+import androidx.compose.ui.text.android.style.TextDecorationSpan
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontSynthesis
@@ -165,7 +164,13 @@
)
assertThat(paragraph.charSequence.toString()).isEqualTo(text)
- assertThat(paragraph.charSequence).hasSpan(StrikethroughSpan::class, 0, text.length)
+ assertThat(paragraph.charSequence).hasSpan(
+ spanClazz = TextDecorationSpan::class,
+ start = 0,
+ end = text.length
+ ) {
+ !it.isUnderlineText && it.isStrikethroughText
+ }
}
@Test
@@ -180,7 +185,13 @@
)
assertThat(paragraph.charSequence.toString()).isEqualTo(text)
- assertThat(paragraph.charSequence).hasSpan(UnderlineSpan::class, 0, text.length)
+ assertThat(paragraph.charSequence).hasSpan(
+ spanClazz = TextDecorationSpan::class,
+ start = 0,
+ end = text.length
+ ) {
+ it.isUnderlineText && !it.isStrikethroughText
+ }
}
@Test
@@ -195,7 +206,13 @@
)
assertThat(paragraph.charSequence.toString()).isEqualTo(text)
- assertThat(paragraph.charSequence).hasSpan(StrikethroughSpan::class, 0, "abc".length)
+ assertThat(paragraph.charSequence).hasSpan(
+ spanClazz = TextDecorationSpan::class,
+ start = 0,
+ end = "abc".length
+ ) {
+ !it.isUnderlineText && it.isStrikethroughText
+ }
}
@Test
@@ -210,7 +227,13 @@
)
assertThat(paragraph.charSequence.toString()).isEqualTo(text)
- assertThat(paragraph.charSequence).hasSpan(UnderlineSpan::class, 0, "abc".length)
+ assertThat(paragraph.charSequence).hasSpan(
+ spanClazz = TextDecorationSpan::class,
+ start = 0,
+ end = "abc".length
+ ) {
+ it.isUnderlineText && !it.isStrikethroughText
+ }
}
@Test
@@ -227,8 +250,13 @@
)
assertThat(paragraph.charSequence.toString()).isEqualTo(text)
- assertThat(paragraph.charSequence).hasSpan(UnderlineSpan::class, 0, "abc".length)
- assertThat(paragraph.charSequence).hasSpan(StrikethroughSpan::class, 0, "abc".length)
+ assertThat(paragraph.charSequence).hasSpan(
+ spanClazz = TextDecorationSpan::class,
+ start = 0,
+ end = "abc".length
+ ) {
+ it.isUnderlineText && it.isStrikethroughText
+ }
}
@Test
@@ -551,7 +579,7 @@
}
@Test
- fun testAnnotatedString_setTextGeometricTransformWithNull_noSpanSet() {
+ fun testAnnotatedString_setDefaultTextGeometricTransform() {
val text = "abcde"
val spanStyle = SpanStyle(textGeometricTransform = TextGeometricTransform())
@@ -561,8 +589,16 @@
width = 100.0f // width is not important
)
- assertThat(paragraph.charSequence).spans(ScaleXSpan::class).isEmpty()
- assertThat(paragraph.charSequence).spans(SkewXSpan::class).isEmpty()
+ assertThat(paragraph.charSequence).hasSpan(ScaleXSpan::class, 0, text.length) {
+ it.scaleX == 1.0f
+ }
+ assertThat(paragraph.charSequence).hasSpan(
+ spanClazz = SkewXSpan::class,
+ start = 0,
+ end = text.length
+ ) {
+ it.skewX == 0.0f
+ }
}
@Test
@@ -584,7 +620,9 @@
assertThat(paragraph.charSequence).hasSpan(ScaleXSpan::class, 0, text.length) {
it.scaleX == scaleX
}
- assertThat(paragraph.charSequence).spans(SkewXSpan::class).isEmpty()
+ assertThat(paragraph.charSequence).hasSpan(SkewXSpan::class, 0, text.length) {
+ it.skewX == 0.0f
+ }
}
@Test
@@ -602,7 +640,9 @@
assertThat(paragraph.charSequence).hasSpan(SkewXSpan::class, 0, text.length) {
it.skewX == skewX
}
- assertThat(paragraph.charSequence).spans(ScaleXSpan::class).isEmpty()
+ assertThat(paragraph.charSequence).hasSpan(ScaleXSpan::class, 0, text.length) {
+ it.scaleX == 1.0f
+ }
}
@Test
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt
index fb3c45d5..8bc21b2 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt
@@ -21,8 +21,10 @@
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ScaleXSpan
+import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.TypefaceSpan
+import android.text.style.UnderlineSpan
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
@@ -39,8 +41,8 @@
import androidx.compose.ui.text.platform.extensions.setColor
import androidx.compose.ui.text.platform.extensions.setFontSize
import androidx.compose.ui.text.platform.extensions.setLocaleList
-import androidx.compose.ui.text.platform.extensions.setTextDecoration
import androidx.compose.ui.text.platform.extensions.toSpan
+import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastForEach
@@ -126,12 +128,29 @@
}
}
- setTextDecoration(spanStyle.textDecoration, start, end)
+ if (spanStyle.textDecoration != null) {
+ // This doesn't match how we rendering the styles. When TextDecoration.None is set, it
+ // should remove the underline and lineThrough effect on the given range. Here we didn't
+ // remove any previously applied spans yet.
+ if (TextDecoration.Underline in spanStyle.textDecoration) {
+ setSpan(
+ UnderlineSpan(),
+ start,
+ end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ }
+ if (TextDecoration.LineThrough in spanStyle.textDecoration) {
+ setSpan(
+ StrikethroughSpan(),
+ start,
+ end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ )
+ }
+ }
- if (
- spanStyle.textGeometricTransform != null &&
- spanStyle.textGeometricTransform.scaleX != 1f
- ) {
+ if (spanStyle.textGeometricTransform != null) {
setSpan(
ScaleXSpan(spanStyle.textGeometricTransform.scaleX),
start,
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt
index 17f672c..f109647 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt
@@ -27,8 +27,6 @@
import android.text.style.MetricAffectingSpan
import android.text.style.RelativeSizeSpan
import android.text.style.ScaleXSpan
-import android.text.style.StrikethroughSpan
-import android.text.style.UnderlineSpan
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.isSpecified
@@ -44,6 +42,7 @@
import androidx.compose.ui.text.android.style.LineHeightSpan
import androidx.compose.ui.text.android.style.ShadowSpan
import androidx.compose.ui.text.android.style.SkewXSpan
+import androidx.compose.ui.text.android.style.TextDecorationSpan
import androidx.compose.ui.text.android.style.TypefaceSpan
import androidx.compose.ui.text.fastFilter
import androidx.compose.ui.text.font.FontStyle
@@ -393,12 +392,8 @@
end: Int
) {
textGeometricTransform?.let {
- if (it.scaleX != 1.0f) {
- setSpan(ScaleXSpan(it.scaleX), start, end)
- }
- if (it.skewX != 0f) {
- setSpan(SkewXSpan(it.skewX), start, end)
- }
+ setSpan(ScaleXSpan(it.scaleX), start, end)
+ setSpan(SkewXSpan(it.skewX), start, end)
}
}
@@ -427,14 +422,14 @@
}
}
+@OptIn(InternalPlatformTextApi::class)
internal fun Spannable.setTextDecoration(textDecoration: TextDecoration?, start: Int, end: Int) {
textDecoration?.let {
- if (TextDecoration.Underline in it) {
- setSpan(UnderlineSpan(), start, end)
- }
- if (TextDecoration.LineThrough in it) {
- setSpan(StrikethroughSpan(), start, end)
- }
+ val textDecorationSpan = TextDecorationSpan(
+ isUnderlineText = TextDecoration.Underline in it,
+ isStrikethroughText = TextDecoration.LineThrough in it
+ )
+ setSpan(textDecorationSpan, start, end)
}
}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
index 622151f..d74228e 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
@@ -443,10 +443,12 @@
@Test
fun dispatchChanges_noNodes_nothingChanges() {
- val (result, _) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(5)))
+ val internalPointerEvent = internalPointerEventOf(down(5))
+
+ hitPathTracker.dispatchChanges(internalPointerEvent)
PointerInputChangeSubject
- .assertThat(result.changes.values.first())
+ .assertThat(internalPointerEvent.changes.values.first())
.isStructurallyEqualTo(down(5))
}
@@ -463,10 +465,12 @@
hitPathTracker.addHitPath(PointerId(13), listOf(pif1))
- val (result, _) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(13)))
+ val internalPointerEvent = internalPointerEventOf(down(13))
+
+ hitPathTracker.dispatchChanges(internalPointerEvent)
PointerInputChangeSubject
- .assertThat(result.changes.values.first())
+ .assertThat(internalPointerEvent.changes.values.first())
.isStructurallyEqualTo(down(13).apply { consumeDownChange() })
}
@@ -508,7 +512,9 @@
val expectedChange = actualChange.deepCopy()
val consumedExpectedChange = actualChange.deepCopy().apply { consumePositionChange() }
- val (result, _) = hitPathTracker.dispatchChanges(internalPointerEventOf(actualChange))
+ val internalPointerEvent = internalPointerEventOf(actualChange)
+
+ hitPathTracker.dispatchChanges(internalPointerEvent)
val log1 = log.getOnPointerEventLog()
.filter { it.pass == PointerEventPass.Initial || it.pass == PointerEventPass.Main }
@@ -570,7 +576,7 @@
assertThat(log1[5].pass).isEqualTo(PointerEventPass.Main)
PointerInputChangeSubject
- .assertThat(result.changes.values.first())
+ .assertThat(internalPointerEvent.changes.values.first())
.isStructurallyEqualTo(
consumedExpectedChange
)
@@ -628,9 +634,9 @@
val consumedExpectedEvent1 = expectedEvent1.deepCopy().apply { consumePositionChange() }
val consumedExpectedEvent2 = expectedEvent2.deepCopy().apply { consumePositionChange() }
- val (result, _) = hitPathTracker.dispatchChanges(
- internalPointerEventOf(actualEvent1, actualEvent2)
- )
+ val internalPointerEvent = internalPointerEventOf(actualEvent1, actualEvent2)
+
+ hitPathTracker.dispatchChanges(internalPointerEvent)
val log1 = log.getOnPointerEventLog()
.filter { it.pass == PointerEventPass.Initial || it.pass == PointerEventPass.Main }
@@ -712,14 +718,14 @@
)
assertThat(log2[3].pass).isEqualTo(PointerEventPass.Main)
- assertThat(result.changes).hasSize(2)
+ assertThat(internalPointerEvent.changes).hasSize(2)
PointerInputChangeSubject
- .assertThat(result.changes[actualEvent1.id])
+ .assertThat(internalPointerEvent.changes[actualEvent1.id])
.isStructurallyEqualTo(
consumedExpectedEvent1
)
PointerInputChangeSubject
- .assertThat(result.changes[actualEvent2.id])
+ .assertThat(internalPointerEvent.changes[actualEvent2.id])
.isStructurallyEqualTo(
consumedExpectedEvent2
)
@@ -770,9 +776,9 @@
val consumedEvent1 = expectedEvent1.deepCopy().apply { consumePositionChange() }
val consumedEvent2 = expectedEvent2.deepCopy().apply { consumePositionChange() }
- val (result, _) = hitPathTracker.dispatchChanges(
- internalPointerEventOf(actualEvent1, actualEvent2)
- )
+ val internalPointerEvent = internalPointerEventOf(actualEvent1, actualEvent2)
+
+ hitPathTracker.dispatchChanges(internalPointerEvent)
val log1 = log.getOnPointerEventLog()
.filter { it.pass == PointerEventPass.Initial || it.pass == PointerEventPass.Main }
@@ -823,13 +829,13 @@
)
assertThat(log1[5].pass).isEqualTo(PointerEventPass.Main)
- assertThat(result.changes).hasSize(2)
+ assertThat(internalPointerEvent.changes).hasSize(2)
PointerInputChangeSubject
- .assertThat(result.changes[actualEvent1.id])
+ .assertThat(internalPointerEvent.changes[actualEvent1.id])
.isStructurallyEqualTo(consumedEvent1)
PointerInputChangeSubject
- .assertThat(result.changes[actualEvent2.id])
+ .assertThat(internalPointerEvent.changes[actualEvent2.id])
.isStructurallyEqualTo(consumedEvent2)
}
@@ -866,9 +872,9 @@
val consumedEvent1 = expectedEvent1.deepCopy().apply { consumePositionChange() }
val consumedEvent2 = expectedEvent2.deepCopy().apply { consumePositionChange() }
- val (result, _) = hitPathTracker.dispatchChanges(
- internalPointerEventOf(actualEvent1, actualEvent2)
- )
+ val internalPointerEvent = internalPointerEventOf(actualEvent1, actualEvent2)
+
+ hitPathTracker.dispatchChanges(internalPointerEvent)
val log1 = log.getOnPointerEventLog()
.filter { it.pass == PointerEventPass.Initial || it.pass == PointerEventPass.Main }
@@ -912,14 +918,14 @@
)
assertThat(log1[3].pass).isEqualTo(PointerEventPass.Main)
- assertThat(result.changes).hasSize(2)
+ assertThat(internalPointerEvent.changes).hasSize(2)
PointerInputChangeSubject
- .assertThat(result.changes[actualEvent1.id])
+ .assertThat(internalPointerEvent.changes[actualEvent1.id])
.isStructurallyEqualTo(
consumedEvent1
)
PointerInputChangeSubject
- .assertThat(result.changes[actualEvent2.id])
+ .assertThat(internalPointerEvent.changes[actualEvent2.id])
.isStructurallyEqualTo(
consumedEvent2
)
@@ -2842,7 +2848,7 @@
@Test
fun dispatchChanges_noNodes_reportsWasDispatchedToNothing() {
- val (_, hitSomething) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(0)))
+ val hitSomething = hitPathTracker.dispatchChanges(internalPointerEventOf(down(0)))
assertThat(hitSomething).isFalse()
}
@@ -2851,7 +2857,7 @@
val pif = PointerInputFilterMock()
hitPathTracker.addHitPath(PointerId(13), listOf(pif))
- val (_, hitSomething) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(13)))
+ val hitSomething = hitPathTracker.dispatchChanges(internalPointerEventOf(down(13)))
assertThat(hitSomething).isTrue()
}
@@ -2861,7 +2867,7 @@
val pif = PointerInputFilterMock()
hitPathTracker.addHitPath(PointerId(13), listOf(pif))
- val (_, hitSomething) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(69)))
+ val hitSomething = hitPathTracker.dispatchChanges(internalPointerEventOf(down(69)))
assertThat(hitSomething).isFalse()
}
@@ -3109,7 +3115,7 @@
if (actualNode.children.size != expectedNode.children.size) {
return false
}
- for (child in actualNode.children) {
+ actualNode.children.forEach { child ->
check = check && expectedNode.children.any {
areEqual(child, it)
}
@@ -3137,7 +3143,7 @@
if (actualNode.children.size != expectedNode.children.size) {
return false
}
- for (child in actualNode.children) {
+ actualNode.children.forEach { child ->
check = check && expectedNode.children.any {
areEqual(child, it)
}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapterTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapterTest.kt
index b55dd28..db78ad7 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapterTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapterTest.kt
@@ -1050,25 +1050,32 @@
arrayOf(PointerCoords(30f, 31f))
)
+ // Test the different events sequentially, since the returned event contains a list that
+ // will be reused by convertToPointerInputEvent for performance, so it shouldn't be held
+ // for longer than needed during the sequential dispatch.
+
val pointerInputEventDown1 = motionEventAdapter.convertToPointerInputEvent(down1)
- val pointerInputEventUp1 = motionEventAdapter.convertToPointerInputEvent(up1)
- val pointerInputEventDown2 = motionEventAdapter.convertToPointerInputEvent(down2)
- val pointerInputEventUp2 = motionEventAdapter.convertToPointerInputEvent(up2)
- val pointerInputEventDown3 = motionEventAdapter.convertToPointerInputEvent(down3)
- val pointerInputEventUp3 = motionEventAdapter.convertToPointerInputEvent(up3)
-
assertThat(pointerInputEventDown1).isNotNull()
- assertThat(pointerInputEventUp1).isNotNull()
- assertThat(pointerInputEventDown2).isNotNull()
- assertThat(pointerInputEventUp2).isNotNull()
- assertThat(pointerInputEventDown3).isNotNull()
- assertThat(pointerInputEventUp3).isNotNull()
-
assertThat(pointerInputEventDown1!!.pointers[0].id).isEqualTo(PointerId(0))
+
+ val pointerInputEventUp1 = motionEventAdapter.convertToPointerInputEvent(up1)
+ assertThat(pointerInputEventUp1).isNotNull()
assertThat(pointerInputEventUp1!!.pointers[0].id).isEqualTo(PointerId(0))
+
+ val pointerInputEventDown2 = motionEventAdapter.convertToPointerInputEvent(down2)
+ assertThat(pointerInputEventDown2).isNotNull()
assertThat(pointerInputEventDown2!!.pointers[0].id).isEqualTo(PointerId(1))
+
+ val pointerInputEventUp2 = motionEventAdapter.convertToPointerInputEvent(up2)
+ assertThat(pointerInputEventUp2).isNotNull()
assertThat(pointerInputEventUp2!!.pointers[0].id).isEqualTo(PointerId(1))
+
+ val pointerInputEventDown3 = motionEventAdapter.convertToPointerInputEvent(down3)
+ assertThat(pointerInputEventDown3).isNotNull()
assertThat(pointerInputEventDown3!!.pointers[0].id).isEqualTo(PointerId(2))
+
+ val pointerInputEventUp3 = motionEventAdapter.convertToPointerInputEvent(up3)
+ assertThat(pointerInputEventUp3).isNotNull()
assertThat(pointerInputEventUp3!!.pointers[0].id).isEqualTo(PointerId(2))
}
@@ -1119,21 +1126,26 @@
)
)
+ // Test the different events sequentially, since the returned event contains a list that
+ // will be reused by convertToPointerInputEvent for performance, so it shouldn't be held
+ // for longer than needed during the sequential dispatch.
+
val pointerInputEventDown1 = motionEventAdapter.convertToPointerInputEvent(down1)
- val pointerInputEventDown2 = motionEventAdapter.convertToPointerInputEvent(down2)
- val pointerInputEventDown3 = motionEventAdapter.convertToPointerInputEvent(down3)
assertThat(pointerInputEventDown1).isNotNull()
- assertThat(pointerInputEventDown2).isNotNull()
- assertThat(pointerInputEventDown3).isNotNull()
-
assertThat(pointerInputEventDown1!!.pointers).hasSize(1)
assertThat(pointerInputEventDown1.pointers[0].id).isEqualTo(PointerId(0))
+ val pointerInputEventDown2 = motionEventAdapter.convertToPointerInputEvent(down2)
+
+ assertThat(pointerInputEventDown2).isNotNull()
assertThat(pointerInputEventDown2!!.pointers).hasSize(2)
assertThat(pointerInputEventDown2.pointers[0].id).isEqualTo(PointerId(0))
assertThat(pointerInputEventDown2.pointers[1].id).isEqualTo(PointerId(1))
+ val pointerInputEventDown3 = motionEventAdapter.convertToPointerInputEvent(down3)
+
+ assertThat(pointerInputEventDown3).isNotNull()
assertThat(pointerInputEventDown3!!.pointers).hasSize(3)
assertThat(pointerInputEventDown2.pointers[0].id).isEqualTo(PointerId(0))
assertThat(pointerInputEventDown2.pointers[1].id).isEqualTo(PointerId(1))
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt
index 1284beb..7ebf120 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt
@@ -43,6 +43,8 @@
@VisibleForTesting
internal val motionEventToComposePointerIdMap: MutableMap<Int, PointerId> = mutableMapOf()
+ private val pointers: MutableList<PointerInputEventData> = mutableListOf()
+
/**
* Converts a single [MotionEvent] from an Android event stream into a [PointerInputEvent], or
* null if the [MotionEvent.getActionMasked] is [ACTION_CANCEL].
@@ -76,12 +78,10 @@
else -> null
}
- // TODO(shepshapard): Avoid allocating for every event.
- val pointers: MutableList<PointerInputEventData> = mutableListOf()
+ pointers.clear()
// This converts the MotionEvent into a list of PointerInputEventData, and updates
// internal record keeping.
- @Suppress("NAME_SHADOWING")
for (i in 0 until motionEvent.pointerCount) {
pointers.add(
createPointerInputEventData(
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt
index ea55590..8bf9f48 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt
@@ -16,6 +16,8 @@
package androidx.compose.ui.input.pointer
+import androidx.compose.runtime.collection.MutableVector
+import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.node.InternalCoreApi
@@ -32,23 +34,6 @@
/*@VisibleForTesting*/
internal val root: NodeParent = NodeParent()
- private val retainedHitPaths: MutableSet<PointerId> = mutableSetOf()
-
- internal interface DispatchChangesRetVal {
- operator fun component1(): InternalPointerEvent
- operator fun component2(): Boolean
- }
-
- private class DispatchChangesRetValImpl : DispatchChangesRetVal {
- lateinit var internalPointerEvent: InternalPointerEvent
- var wasDispatchedToSomething: Boolean = false
- override operator fun component1() = internalPointerEvent
- override operator fun component2() = wasDispatchedToSomething
- }
-
- // See https://youtrack.jetbrains.com/issue/KT-39905.
- private val dispatchChangesRetVal = DispatchChangesRetValImpl()
-
/**
* Associates a [pointerId] to a list of hit [pointerInputFilters] and keeps track of them.
*
@@ -67,9 +52,11 @@
eachPin@ for (i in pointerInputFilters.indices) {
val pointerInputFilter = pointerInputFilters[i]
if (merging) {
- val node = parent.children.find { it.pointerInputFilter == pointerInputFilter }
+ val node = parent.children.firstOrNull {
+ it.pointerInputFilter == pointerInputFilter
+ }
if (node != null) {
- node.pointerIds.add(pointerId)
+ if (pointerId !in node.pointerIds) node.pointerIds.add(pointerId)
parent = node
continue@eachPin
} else {
@@ -89,26 +76,17 @@
* therefore no longer associated with any pointer ids.
*/
fun removeHitPath(pointerId: PointerId) {
- removeHitPathInternal(pointerId)
- removeHitPathInternal(pointerId)
+ root.recursivelyRemovePointerId(pointerId)
}
/**
* Dispatches [internalPointerEvent] through the hierarchy.
*
- * Returns a [DispatchChangesRetVal] that should not be referenced directly, but instead
- * should be destrutured immediately. Each instance of [HitPathTracker] reuses a single
- * [DispatchChangesRetVal] and mutates it for each return for performance reasons.
- *
- * [DispatchChangesRetVal.component1] references the resulting changes after dispatch.
- * [DispatchChangesRetVal.component2] is true if the dispatch reached at least one
- * [PointerInputModifier].
- *
* @param internalPointerEvent The change to dispatch.
*
- * @return The DispatchChangesRetVal that should be destructured immediately.
+ * @return whether this event was dispatched to a [PointerInputFilter]
*/
- fun dispatchChanges(internalPointerEvent: InternalPointerEvent): DispatchChangesRetVal {
+ fun dispatchChanges(internalPointerEvent: InternalPointerEvent): Boolean {
var dispatchHit = root.dispatchMainEventPass(
internalPointerEvent.changes,
rootCoordinates,
@@ -116,9 +94,7 @@
)
dispatchHit = root.dispatchFinalEventPass() || dispatchHit
- dispatchChangesRetVal.wasDispatchedToSomething = dispatchHit
- dispatchChangesRetVal.internalPointerEvent = internalPointerEvent
- return dispatchChangesRetVal
+ return dispatchHit
}
/**
@@ -128,7 +104,6 @@
* data.
*/
fun processCancel() {
- retainedHitPaths.clear()
root.dispatchCancel()
root.clear()
}
@@ -142,13 +117,6 @@
fun removeDetachedPointerInputFilters() {
root.removeDetachedPointerInputFilters()
}
-
- /**
- * Actually removes hit paths.
- */
- private fun removeHitPathInternal(pointerId: PointerId) {
- root.removePointerId(pointerId)
- }
}
/**
@@ -159,7 +127,7 @@
/*@VisibleForTesting*/
@OptIn(InternalCoreApi::class)
internal open class NodeParent {
- val children: MutableSet<Node> = mutableSetOf()
+ val children: MutableVector<Node> = mutableVectorOf()
/**
* Dispatches [changes] down the tree, for the initial and main pass.
@@ -221,54 +189,33 @@
* Removes all child [Node]s that are no longer attached to the compose tree.
*/
fun removeDetachedPointerInputFilters() {
- children.removeAndProcess(
- removeIf = {
- !it.pointerInputFilter.isAttached
- },
- ifRemoved = {
- it.dispatchCancel()
- },
- ifKept = {
- it.removeDetachedPointerInputFilters()
+ var index = 0
+ while (index < children.size) {
+ val child = children[index]
+ if (!child.pointerInputFilter.isAttached) {
+ children.removeAt(index)
+ child.dispatchCancel()
+ } else {
+ index++
+ child.removeDetachedPointerInputFilters()
}
- )
+ }
}
/**
* Removes the tracking of [pointerId] and removes all child [Node]s that are no longer
- * tracking
- * any [PointerId]s.
+ * tracking any [PointerId]s.
*/
- fun removePointerId(pointerId: PointerId) {
- children.forEach {
- it.pointerIds.remove(pointerId)
- }
- children.removeAll {
- it.pointerIds.isEmpty()
- }
- children.forEach {
- it.removePointerId(pointerId)
- }
- }
-
- /**
- * With each item, if calling [removeIf] with it is true, removes the item from [this] and calls
- * [ifRemoved] with it, otherwise calls [ifKept] with it.
- */
- private fun <T> MutableIterable<T>.removeAndProcess(
- removeIf: (T) -> Boolean,
- ifRemoved: (T) -> Unit,
- ifKept: (T) -> Unit
- ) {
- with(iterator()) {
- while (hasNext()) {
- val next = next()
- if (removeIf(next)) {
- remove()
- ifRemoved(next)
- } else {
- ifKept(next)
- }
+ fun recursivelyRemovePointerId(pointerId: PointerId) {
+ var index = 0
+ while (index < children.size) {
+ val child = children[index]
+ child.pointerIds.remove(pointerId)
+ if (child.pointerIds.isEmpty()) {
+ children.removeAt(index)
+ } else {
+ child.recursivelyRemovePointerId(pointerId)
+ index++
}
}
}
@@ -282,7 +229,13 @@
@OptIn(InternalCoreApi::class)
internal class Node(val pointerInputFilter: PointerInputFilter) : NodeParent() {
- val pointerIds: MutableSet<PointerId> = mutableSetOf()
+ // Note: this is essentially a set, and writes should be guarded accordingly. We use a
+ // MutableVector here instead since a set ends up being quite heavy, and calls to
+ // set.contains() show up noticeably (~1%) in traces. Since the maximum size of this vector
+ // is small (due to the limited amount of concurrent PointerIds there _could_ be), iterating
+ // through the small vector in most cases should have a lower performance impact than using a
+ // set.
+ val pointerIds: MutableVector<PointerId> = mutableVectorOf()
/**
* Cached properties that will be set before the main event pass, and reset after the final
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt
index 54db299..35ccd12 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt
@@ -34,6 +34,7 @@
private val hitPathTracker = HitPathTracker(root.coordinates)
private val pointerInputChangeEventProducer = PointerInputChangeEventProducer()
+ private val hitResult: MutableList<PointerInputFilter> = mutableListOf()
/**
* Receives [PointerInputEvent]s and process them through the tree rooted on [root].
@@ -54,44 +55,41 @@
val internalPointerEvent =
pointerInputChangeEventProducer.produce(pointerEvent, positionCalculator)
- // TODO(shepshapard): Create fast forEach for maps?
-
// Add new hit paths to the tracker due to down events.
- internalPointerEvent
- .changes
- .filter { (_, pointerInputChange) -> pointerInputChange.changedToDownIgnoreConsumed() }
- .forEach { (_, pointerInputChange) ->
- val hitResult: MutableList<PointerInputFilter> = mutableListOf()
+ internalPointerEvent.changes.values.forEach { pointerInputChange ->
+ if (pointerInputChange.changedToDownIgnoreConsumed()) {
root.hitTest(
pointerInputChange.position,
hitResult
)
if (hitResult.isNotEmpty()) {
hitPathTracker.addHitPath(pointerInputChange.id, hitResult)
+ hitResult.clear()
}
}
+ }
// Remove [PointerInputFilter]s that are no longer valid and refresh the offset information
// for those that are.
hitPathTracker.removeDetachedPointerInputFilters()
// Dispatch to PointerInputFilters
- val (resultingChanges, dispatchedToSomething) =
- hitPathTracker.dispatchChanges(internalPointerEvent)
+ val dispatchedToSomething = hitPathTracker.dispatchChanges(internalPointerEvent)
- // Remove hit paths from the tracker due to up events.
- internalPointerEvent
- .changes
- .filter { (_, pointerInputChange) -> pointerInputChange.changedToUpIgnoreConsumed() }
- .forEach { (_, pointerInputChange) ->
+ var anyMovementConsumed = false
+
+ // Remove hit paths from the tracker due to up events, and calculate if we have consumed
+ // any movement
+ internalPointerEvent.changes.values.forEach { pointerInputChange ->
+ if (pointerInputChange.changedToUpIgnoreConsumed()) {
hitPathTracker.removeHitPath(pointerInputChange.id)
}
+ if (pointerInputChange.positionChangeConsumed()) {
+ anyMovementConsumed = true
+ }
+ }
- // TODO(shepshapard): Don't allocate on every call.
- return ProcessResult(
- dispatchedToSomething,
- resultingChanges.changes.any { (_, value) -> value.positionChangeConsumed() }
- )
+ return ProcessResult(dispatchedToSomething, anyMovementConsumed)
}
/**
@@ -154,8 +152,7 @@
previousPointerInputData[it.id] = PointerInputData(
it.uptime,
it.positionOnScreen,
- it.down,
- it.type
+ it.down
)
} else {
previousPointerInputData.remove(it.id)
@@ -167,15 +164,14 @@
/**
* Clears all tracked information.
*/
- internal fun clear() {
+ fun clear() {
previousPointerInputData.clear()
}
private class PointerInputData(
val uptime: Long,
val positionOnScreen: Offset,
- val down: Boolean,
- val type: PointerType
+ val down: Boolean
)
}
@@ -183,7 +179,6 @@
* The result of a call to [PointerInputEventProcessor.process].
*/
// TODO(shepshpard): Not sure if storing these values in a int is most efficient overall.
-@Suppress("EXPERIMENTAL_FEATURE_WARNING")
internal inline class ProcessResult(private val value: Int) {
val dispatchedToAPointerInputModifier
get() = (value and 1) != 0
diff --git a/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java
index b6767d5..9ecc7281 100644
--- a/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java
@@ -169,7 +169,7 @@
@Test
@SdkSuppress(minSdkVersion = 19, maxSdkVersion = 27)
- @FlakyTest
+ @FlakyTest(bugId = 187190911)
public void testAccessibilityPaneTitle_isntTrackedAsPaneWithoutTitle() {
// This test isn't to test the propagation up, just that the event is sent correctly
ViewCompat.setAccessibilityLiveRegion(mView,
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java
index b6bae78..ce23b2f 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java
@@ -4567,7 +4567,7 @@
assertEquals(0, mGridView.getSelectedPosition());
}
- @FlakyTest
+ @FlakyTest(bugId = 187191618)
@Test
public void testExtraLayoutSpace() throws Throwable {
Intent intent = new Intent();
diff --git a/text/text/src/main/java/androidx/compose/ui/text/android/style/TextDecorationSpan.kt b/text/text/src/main/java/androidx/compose/ui/text/android/style/TextDecorationSpan.kt
new file mode 100644
index 0000000..afa6802
--- /dev/null
+++ b/text/text/src/main/java/androidx/compose/ui/text/android/style/TextDecorationSpan.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 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 androidx.compose.ui.text.android.style
+
+import android.text.TextPaint
+import android.text.style.CharacterStyle
+import androidx.compose.ui.text.android.InternalPlatformTextApi
+
+/**
+ * A span which applies the underline and strike through to the affected text.
+ *
+ * @property isUnderlineText whether to draw the under for the affected text.
+ * @property isStrikethroughText whether to draw strikethrough line for the affected text.
+ * @suppress
+ */
+@InternalPlatformTextApi
+class TextDecorationSpan(
+ val isUnderlineText: Boolean,
+ val isStrikethroughText: Boolean
+) : CharacterStyle() {
+ override fun updateDrawState(textPaint: TextPaint) {
+ textPaint.isUnderlineText = isUnderlineText
+ textPaint.isStrikeThruText = isStrikethroughText
+ }
+}
\ No newline at end of file