bb6f6e5dae
This reverts commit 40a7421619.
Reason for revert: DroidMonitor-triggered revert due to breakage https://android-build.googleplex.com/builds/tests/view?invocationId=I13700009003387451&testResultId=TR89423459137251402, bug https://buganizer.corp.google.com/issues/188755902
Bug: 188755902
Change-Id: Icd1ddd43e62d392f6b68b0150a1075f73106391a
519 lines
20 KiB
Java
519 lines
20 KiB
Java
/*
|
|
* Copyright (C) 2019 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
package com.android.launcher3.hybridhotseat;
|
|
|
|
import static com.android.launcher3.LauncherAnimUtils.SCALE_PROPERTY;
|
|
import static com.android.launcher3.LauncherState.NORMAL;
|
|
import static com.android.launcher3.anim.AnimatorListeners.forSuccessCallback;
|
|
import static com.android.launcher3.hybridhotseat.HotseatEduController.getSettingsIntent;
|
|
import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_HOTSEAT_PREDICTION_PINNED;
|
|
import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_HOTSEAT_RANKED;
|
|
import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
|
|
|
|
import android.animation.Animator;
|
|
import android.animation.AnimatorSet;
|
|
import android.animation.ObjectAnimator;
|
|
import android.content.ComponentName;
|
|
import android.view.HapticFeedbackConstants;
|
|
import android.view.View;
|
|
import android.view.ViewGroup;
|
|
|
|
import androidx.annotation.NonNull;
|
|
import androidx.annotation.Nullable;
|
|
|
|
import com.android.launcher3.DeviceProfile;
|
|
import com.android.launcher3.DragSource;
|
|
import com.android.launcher3.DropTarget;
|
|
import com.android.launcher3.Hotseat;
|
|
import com.android.launcher3.LauncherSettings;
|
|
import com.android.launcher3.R;
|
|
import com.android.launcher3.anim.AnimationSuccessListener;
|
|
import com.android.launcher3.config.FeatureFlags;
|
|
import com.android.launcher3.dragndrop.DragController;
|
|
import com.android.launcher3.dragndrop.DragOptions;
|
|
import com.android.launcher3.graphics.DragPreviewProvider;
|
|
import com.android.launcher3.logger.LauncherAtom.ContainerInfo;
|
|
import com.android.launcher3.logger.LauncherAtom.PredictedHotseatContainer;
|
|
import com.android.launcher3.logging.InstanceId;
|
|
import com.android.launcher3.model.BgDataModel.FixedContainerItems;
|
|
import com.android.launcher3.model.data.ItemInfo;
|
|
import com.android.launcher3.model.data.WorkspaceItemInfo;
|
|
import com.android.launcher3.popup.SystemShortcut;
|
|
import com.android.launcher3.touch.ItemLongClickListener;
|
|
import com.android.launcher3.uioverrides.PredictedAppIcon;
|
|
import com.android.launcher3.uioverrides.QuickstepLauncher;
|
|
import com.android.launcher3.util.OnboardingPrefs;
|
|
import com.android.launcher3.views.ArrowTipView;
|
|
import com.android.launcher3.views.Snackbar;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.stream.Collectors;
|
|
|
|
/**
|
|
* Provides prediction ability for the hotseat. Fills gaps in hotseat with predicted items, allows
|
|
* pinning of predicted apps and manages replacement of predicted apps with user drag.
|
|
*/
|
|
public class HotseatPredictionController implements DragController.DragListener,
|
|
SystemShortcut.Factory<QuickstepLauncher>, DeviceProfile.OnDeviceProfileChangeListener,
|
|
DragSource, ViewGroup.OnHierarchyChangeListener {
|
|
|
|
private static final int FLAG_UPDATE_PAUSED = 1 << 0;
|
|
private static final int FLAG_DRAG_IN_PROGRESS = 1 << 1;
|
|
private static final int FLAG_FILL_IN_PROGRESS = 1 << 2;
|
|
private static final int FLAG_REMOVING_PREDICTED_ICON = 1 << 3;
|
|
|
|
private int mHotSeatItemsCount;
|
|
|
|
private QuickstepLauncher mLauncher;
|
|
private final Hotseat mHotseat;
|
|
private final Runnable mUpdateFillIfNotLoading = this::updateFillIfNotLoading;
|
|
|
|
private List<ItemInfo> mPredictedItems = Collections.emptyList();
|
|
|
|
private AnimatorSet mIconRemoveAnimators;
|
|
private int mPauseFlags = 0;
|
|
|
|
private List<PredictedAppIcon.PredictedIconOutlineDrawing> mOutlineDrawings = new ArrayList<>();
|
|
|
|
private final View.OnLongClickListener mPredictionLongClickListener = v -> {
|
|
if (!ItemLongClickListener.canStartDrag(mLauncher)) return false;
|
|
if (mLauncher.getWorkspace().isSwitchingState()) return false;
|
|
if (!mLauncher.getOnboardingPrefs().getBoolean(
|
|
OnboardingPrefs.HOTSEAT_LONGPRESS_TIP_SEEN)) {
|
|
Snackbar.show(mLauncher, R.string.hotseat_tip_gaps_filled,
|
|
R.string.hotseat_prediction_settings, null,
|
|
() -> mLauncher.startActivity(getSettingsIntent()));
|
|
mLauncher.getOnboardingPrefs().markChecked(OnboardingPrefs.HOTSEAT_LONGPRESS_TIP_SEEN);
|
|
mLauncher.getDragLayer().performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
|
|
return true;
|
|
}
|
|
|
|
// Start the drag
|
|
// Use a new itemInfo so that the original predicted item is stable
|
|
WorkspaceItemInfo dragItem = new WorkspaceItemInfo((WorkspaceItemInfo) v.getTag());
|
|
v.setVisibility(View.INVISIBLE);
|
|
mLauncher.getWorkspace().beginDragShared(
|
|
v, null, this, dragItem, new DragPreviewProvider(v),
|
|
mLauncher.getDefaultWorkspaceDragOptions());
|
|
return true;
|
|
};
|
|
|
|
public HotseatPredictionController(QuickstepLauncher launcher) {
|
|
mLauncher = launcher;
|
|
mHotseat = launcher.getHotseat();
|
|
mHotSeatItemsCount = mLauncher.getDeviceProfile().numShownHotseatIcons;
|
|
mLauncher.getDragController().addDragListener(this);
|
|
|
|
launcher.addOnDeviceProfileChangeListener(this);
|
|
mHotseat.getShortcutsAndWidgets().setOnHierarchyChangeListener(this);
|
|
}
|
|
|
|
@Override
|
|
public void onChildViewAdded(View parent, View child) {
|
|
onHotseatHierarchyChanged();
|
|
}
|
|
|
|
@Override
|
|
public void onChildViewRemoved(View parent, View child) {
|
|
onHotseatHierarchyChanged();
|
|
}
|
|
|
|
private void onHotseatHierarchyChanged() {
|
|
if (mPauseFlags == 0 && !mLauncher.isWorkspaceLoading()) {
|
|
// Post update after a single frame to avoid layout within layout
|
|
MAIN_EXECUTOR.getHandler().removeCallbacks(mUpdateFillIfNotLoading);
|
|
MAIN_EXECUTOR.getHandler().post(mUpdateFillIfNotLoading);
|
|
}
|
|
}
|
|
|
|
private void updateFillIfNotLoading() {
|
|
if (mPauseFlags == 0 && !mLauncher.isWorkspaceLoading()) {
|
|
fillGapsWithPrediction(true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows appropriate hotseat education based on prediction enabled and migration states.
|
|
*/
|
|
public void showEdu() {
|
|
mLauncher.getStateManager().goToState(NORMAL, true, forSuccessCallback(() -> {
|
|
if (mPredictedItems.isEmpty()) {
|
|
// launcher has empty predictions set
|
|
Snackbar.show(mLauncher, R.string.hotsaet_tip_prediction_disabled,
|
|
R.string.hotseat_prediction_settings, null,
|
|
() -> mLauncher.startActivity(getSettingsIntent()));
|
|
} else if (getPredictedIcons().size() >= (mHotSeatItemsCount + 1) / 2) {
|
|
showDiscoveryTip();
|
|
} else {
|
|
HotseatEduController eduController = new HotseatEduController(mLauncher);
|
|
eduController.setPredictedApps(mPredictedItems.stream()
|
|
.map(i -> (WorkspaceItemInfo) i)
|
|
.collect(Collectors.toList()));
|
|
eduController.showEdu();
|
|
}
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Shows educational tip for hotseat if user does not go through Tips app.
|
|
*/
|
|
private void showDiscoveryTip() {
|
|
if (getPredictedIcons().isEmpty()) {
|
|
new ArrowTipView(mLauncher).show(
|
|
mLauncher.getString(R.string.hotseat_tip_no_empty_slots), mHotseat.getTop());
|
|
} else {
|
|
Snackbar.show(mLauncher, R.string.hotseat_tip_gaps_filled,
|
|
R.string.hotseat_prediction_settings, null,
|
|
() -> mLauncher.startActivity(getSettingsIntent()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns if hotseat client has predictions
|
|
*/
|
|
public boolean hasPredictions() {
|
|
return !mPredictedItems.isEmpty();
|
|
}
|
|
|
|
private void fillGapsWithPrediction() {
|
|
fillGapsWithPrediction(false);
|
|
}
|
|
|
|
private void fillGapsWithPrediction(boolean animate) {
|
|
if (mPauseFlags != 0) {
|
|
return;
|
|
}
|
|
|
|
int predictionIndex = 0;
|
|
ArrayList<WorkspaceItemInfo> newItems = new ArrayList<>();
|
|
// make sure predicted icon removal and filling predictions don't step on each other
|
|
if (mIconRemoveAnimators != null && mIconRemoveAnimators.isRunning()) {
|
|
mIconRemoveAnimators.addListener(new AnimationSuccessListener() {
|
|
@Override
|
|
public void onAnimationSuccess(Animator animator) {
|
|
fillGapsWithPrediction(animate);
|
|
mIconRemoveAnimators.removeListener(this);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
mPauseFlags |= FLAG_FILL_IN_PROGRESS;
|
|
for (int rank = 0; rank < mHotSeatItemsCount; rank++) {
|
|
View child = mHotseat.getChildAt(
|
|
mHotseat.getCellXFromOrder(rank),
|
|
mHotseat.getCellYFromOrder(rank));
|
|
|
|
if (child != null && !isPredictedIcon(child)) {
|
|
continue;
|
|
}
|
|
if (mPredictedItems.size() <= predictionIndex) {
|
|
// Remove predicted apps from the past
|
|
if (isPredictedIcon(child)) {
|
|
mHotseat.removeView(child);
|
|
}
|
|
continue;
|
|
}
|
|
WorkspaceItemInfo predictedItem =
|
|
(WorkspaceItemInfo) mPredictedItems.get(predictionIndex++);
|
|
if (isPredictedIcon(child) && child.isEnabled()) {
|
|
PredictedAppIcon icon = (PredictedAppIcon) child;
|
|
icon.applyFromWorkspaceItem(predictedItem);
|
|
icon.finishBinding(mPredictionLongClickListener);
|
|
} else {
|
|
newItems.add(predictedItem);
|
|
}
|
|
preparePredictionInfo(predictedItem, rank);
|
|
}
|
|
bindItems(newItems, animate);
|
|
|
|
mPauseFlags &= ~FLAG_FILL_IN_PROGRESS;
|
|
}
|
|
|
|
private void bindItems(List<WorkspaceItemInfo> itemsToAdd, boolean animate) {
|
|
AnimatorSet animationSet = new AnimatorSet();
|
|
for (WorkspaceItemInfo item : itemsToAdd) {
|
|
PredictedAppIcon icon = PredictedAppIcon.createIcon(mHotseat, item);
|
|
mLauncher.getWorkspace().addInScreenFromBind(icon, item);
|
|
icon.finishBinding(mPredictionLongClickListener);
|
|
if (animate) {
|
|
animationSet.play(ObjectAnimator.ofFloat(icon, SCALE_PROPERTY, 0.2f, 1));
|
|
}
|
|
}
|
|
if (animate) {
|
|
animationSet.addListener(
|
|
forSuccessCallback(this::removeOutlineDrawings));
|
|
animationSet.start();
|
|
} else {
|
|
removeOutlineDrawings();
|
|
}
|
|
|
|
if (mLauncher.getTaskbarController() != null) {
|
|
mLauncher.getTaskbarController().onHotseatUpdated();
|
|
}
|
|
}
|
|
|
|
private void removeOutlineDrawings() {
|
|
if (mOutlineDrawings.isEmpty()) return;
|
|
for (PredictedAppIcon.PredictedIconOutlineDrawing outlineDrawing : mOutlineDrawings) {
|
|
mHotseat.removeDelegatedCellDrawing(outlineDrawing);
|
|
}
|
|
mHotseat.invalidate();
|
|
mOutlineDrawings.clear();
|
|
}
|
|
|
|
|
|
/**
|
|
* Unregisters callbacks and frees resources
|
|
*/
|
|
public void destroy() {
|
|
mLauncher.removeOnDeviceProfileChangeListener(this);
|
|
}
|
|
|
|
/**
|
|
* start and pauses predicted apps update on the hotseat
|
|
*/
|
|
public void setPauseUIUpdate(boolean paused) {
|
|
mPauseFlags = paused
|
|
? (mPauseFlags | FLAG_UPDATE_PAUSED)
|
|
: (mPauseFlags & ~FLAG_UPDATE_PAUSED);
|
|
if (!paused) {
|
|
fillGapsWithPrediction();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets or updates the predicted items
|
|
*/
|
|
public void setPredictedItems(FixedContainerItems items) {
|
|
boolean shouldIgnoreVisibility = FeatureFlags.ENABLE_APP_PREDICTIONS_WHILE_VISIBLE.get()
|
|
|| mLauncher.isWorkspaceLoading()
|
|
|| mPredictedItems.equals(items.items)
|
|
|| mHotseat.getShortcutsAndWidgets().getChildCount() < mHotSeatItemsCount;
|
|
if (!shouldIgnoreVisibility
|
|
&& mHotseat.isShown()
|
|
&& mHotseat.getWindowVisibility() == View.VISIBLE) {
|
|
mHotseat.setOnVisibilityAggregatedCallback((isVisible) -> {
|
|
if (isVisible) {
|
|
return;
|
|
}
|
|
mHotseat.setOnVisibilityAggregatedCallback(null);
|
|
|
|
applyPredictedItems(items);
|
|
});
|
|
} else {
|
|
mHotseat.setOnVisibilityAggregatedCallback(null);
|
|
|
|
applyPredictedItems(items);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets or updates the predicted items only once the hotseat becomes hidden to the user
|
|
*/
|
|
private void applyPredictedItems(FixedContainerItems items) {
|
|
mPredictedItems = items.items;
|
|
if (mPredictedItems.isEmpty()) {
|
|
HotseatRestoreHelper.restoreBackup(mLauncher);
|
|
}
|
|
fillGapsWithPrediction();
|
|
}
|
|
|
|
/**
|
|
* Pins a predicted app icon into place.
|
|
*/
|
|
public void pinPrediction(ItemInfo info) {
|
|
PredictedAppIcon icon = (PredictedAppIcon) mHotseat.getChildAt(
|
|
mHotseat.getCellXFromOrder(info.rank),
|
|
mHotseat.getCellYFromOrder(info.rank));
|
|
if (icon == null) {
|
|
return;
|
|
}
|
|
WorkspaceItemInfo workspaceItemInfo = new WorkspaceItemInfo((WorkspaceItemInfo) info);
|
|
mLauncher.getModelWriter().addItemToDatabase(workspaceItemInfo,
|
|
LauncherSettings.Favorites.CONTAINER_HOTSEAT, workspaceItemInfo.screenId,
|
|
workspaceItemInfo.cellX, workspaceItemInfo.cellY);
|
|
ObjectAnimator.ofFloat(icon, SCALE_PROPERTY, 1, 0.8f, 1).start();
|
|
icon.pin(workspaceItemInfo);
|
|
mLauncher.getStatsLogManager().logger()
|
|
.withItemInfo(workspaceItemInfo)
|
|
.log(LAUNCHER_HOTSEAT_PREDICTION_PINNED);
|
|
}
|
|
|
|
private List<PredictedAppIcon> getPredictedIcons() {
|
|
List<PredictedAppIcon> icons = new ArrayList<>();
|
|
ViewGroup vg = mHotseat.getShortcutsAndWidgets();
|
|
for (int i = 0; i < vg.getChildCount(); i++) {
|
|
View child = vg.getChildAt(i);
|
|
if (isPredictedIcon(child)) {
|
|
icons.add((PredictedAppIcon) child);
|
|
}
|
|
}
|
|
return icons;
|
|
}
|
|
|
|
private void removePredictedApps(List<PredictedAppIcon.PredictedIconOutlineDrawing> outlines,
|
|
DropTarget.DragObject dragObject) {
|
|
if (mIconRemoveAnimators != null) {
|
|
mIconRemoveAnimators.end();
|
|
}
|
|
mIconRemoveAnimators = new AnimatorSet();
|
|
removeOutlineDrawings();
|
|
for (PredictedAppIcon icon : getPredictedIcons()) {
|
|
if (!icon.isEnabled()) {
|
|
continue;
|
|
}
|
|
if (dragObject.dragSource == this && icon.equals(dragObject.originalView)) {
|
|
removeIconWithoutNotify(icon);
|
|
continue;
|
|
}
|
|
int rank = ((WorkspaceItemInfo) icon.getTag()).rank;
|
|
outlines.add(new PredictedAppIcon.PredictedIconOutlineDrawing(
|
|
mHotseat.getCellXFromOrder(rank), mHotseat.getCellYFromOrder(rank), icon));
|
|
icon.setEnabled(false);
|
|
ObjectAnimator animator = ObjectAnimator.ofFloat(icon, SCALE_PROPERTY, 0);
|
|
animator.addListener(new AnimationSuccessListener() {
|
|
@Override
|
|
public void onAnimationSuccess(Animator animator) {
|
|
if (icon.getParent() != null) {
|
|
removeIconWithoutNotify(icon);
|
|
}
|
|
}
|
|
});
|
|
mIconRemoveAnimators.play(animator);
|
|
}
|
|
mIconRemoveAnimators.start();
|
|
}
|
|
|
|
/**
|
|
* Removes icon while suppressing any extra tasks performed on view-hierarchy changes.
|
|
* This avoids recursive/redundant updates as the control updates the UI anyway after
|
|
* it's animation.
|
|
*/
|
|
private void removeIconWithoutNotify(PredictedAppIcon icon) {
|
|
mPauseFlags |= FLAG_REMOVING_PREDICTED_ICON;
|
|
mHotseat.removeView(icon);
|
|
mPauseFlags &= ~FLAG_REMOVING_PREDICTED_ICON;
|
|
}
|
|
|
|
@Override
|
|
public void onDragStart(DropTarget.DragObject dragObject, DragOptions options) {
|
|
removePredictedApps(mOutlineDrawings, dragObject);
|
|
if (mOutlineDrawings.isEmpty()) return;
|
|
for (PredictedAppIcon.PredictedIconOutlineDrawing outlineDrawing : mOutlineDrawings) {
|
|
mHotseat.addDelegatedCellDrawing(outlineDrawing);
|
|
}
|
|
mPauseFlags |= FLAG_DRAG_IN_PROGRESS;
|
|
mHotseat.invalidate();
|
|
}
|
|
|
|
@Override
|
|
public void onDragEnd() {
|
|
mPauseFlags &= ~FLAG_DRAG_IN_PROGRESS;
|
|
fillGapsWithPrediction(true);
|
|
}
|
|
|
|
@Nullable
|
|
@Override
|
|
public SystemShortcut<QuickstepLauncher> getShortcut(QuickstepLauncher activity,
|
|
ItemInfo itemInfo) {
|
|
if (itemInfo.container != LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) {
|
|
return null;
|
|
}
|
|
return new PinPrediction(activity, itemInfo);
|
|
}
|
|
|
|
private void preparePredictionInfo(WorkspaceItemInfo itemInfo, int rank) {
|
|
itemInfo.container = LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION;
|
|
itemInfo.rank = rank;
|
|
itemInfo.cellX = mHotseat.getCellXFromOrder(rank);
|
|
itemInfo.cellY = mHotseat.getCellYFromOrder(rank);
|
|
itemInfo.screenId = rank;
|
|
}
|
|
|
|
@Override
|
|
public void onDeviceProfileChanged(DeviceProfile profile) {
|
|
this.mHotSeatItemsCount = profile.numShownHotseatIcons;
|
|
}
|
|
|
|
@Override
|
|
public void onDropCompleted(View target, DropTarget.DragObject d, boolean success) {
|
|
//Does nothing
|
|
}
|
|
|
|
/**
|
|
* Logs rank info based on current list of predicted items
|
|
*/
|
|
public void logLaunchedAppRankingInfo(@NonNull ItemInfo itemInfo, InstanceId instanceId) {
|
|
ComponentName targetCN = itemInfo.getTargetComponent();
|
|
if (targetCN == null) {
|
|
return;
|
|
}
|
|
int rank = -1;
|
|
for (int i = mPredictedItems.size() - 1; i >= 0; i--) {
|
|
ItemInfo info = mPredictedItems.get(i);
|
|
if (targetCN.equals(info.getTargetComponent()) && itemInfo.user.equals(info.user)) {
|
|
rank = i;
|
|
break;
|
|
}
|
|
}
|
|
if (rank < 0) {
|
|
return;
|
|
}
|
|
|
|
int cardinality = 0;
|
|
for (PredictedAppIcon icon : getPredictedIcons()) {
|
|
ItemInfo info = (ItemInfo) icon.getTag();
|
|
cardinality |= 1 << info.screenId;
|
|
}
|
|
|
|
PredictedHotseatContainer.Builder containerBuilder = PredictedHotseatContainer.newBuilder();
|
|
containerBuilder.setCardinality(cardinality);
|
|
if (itemInfo.container == LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) {
|
|
containerBuilder.setIndex(rank);
|
|
}
|
|
mLauncher.getStatsLogManager().logger()
|
|
.withInstanceId(instanceId)
|
|
.withRank(rank)
|
|
.withContainerInfo(ContainerInfo.newBuilder()
|
|
.setPredictedHotseatContainer(containerBuilder)
|
|
.build())
|
|
.log(LAUNCHER_HOTSEAT_RANKED);
|
|
}
|
|
|
|
private class PinPrediction extends SystemShortcut<QuickstepLauncher> {
|
|
|
|
private PinPrediction(QuickstepLauncher target, ItemInfo itemInfo) {
|
|
super(R.drawable.ic_pin, R.string.pin_prediction, target,
|
|
itemInfo);
|
|
}
|
|
|
|
@Override
|
|
public void onClick(View view) {
|
|
dismissTaskMenuView(mTarget);
|
|
pinPrediction(mItemInfo);
|
|
}
|
|
}
|
|
|
|
private static boolean isPredictedIcon(View view) {
|
|
return view instanceof PredictedAppIcon && view.getTag() instanceof WorkspaceItemInfo
|
|
&& ((WorkspaceItemInfo) view.getTag()).container
|
|
== LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION;
|
|
}
|
|
}
|