diff options
Diffstat (limited to 'base/src/main/java/com/smartdevicelink/managers/screen/menu/MenuReplaceOperation.java')
-rw-r--r-- | base/src/main/java/com/smartdevicelink/managers/screen/menu/MenuReplaceOperation.java | 558 |
1 files changed, 558 insertions, 0 deletions
diff --git a/base/src/main/java/com/smartdevicelink/managers/screen/menu/MenuReplaceOperation.java b/base/src/main/java/com/smartdevicelink/managers/screen/menu/MenuReplaceOperation.java new file mode 100644 index 000000000..7db1a85b9 --- /dev/null +++ b/base/src/main/java/com/smartdevicelink/managers/screen/menu/MenuReplaceOperation.java @@ -0,0 +1,558 @@ +/* + * Copyright (c) 2021 Livio, Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following + * disclaimer in the documentation and/or other materials provided with the + * distribution. + * + * Neither the name of the Livio Inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +package com.smartdevicelink.managers.screen.menu; + +import static com.smartdevicelink.managers.ManagerUtility.WindowCapabilityUtility.hasImageFieldOfName; +import static com.smartdevicelink.managers.ManagerUtility.WindowCapabilityUtility.hasTextFieldOfName; +import static com.smartdevicelink.managers.screen.menu.BaseMenuManager.parentIdNotFound; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.addCellWithCellId; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.addIdsToMenuCells; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.cloneMenuCellsList; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.commandIdForRPCRequest; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.deleteCommandsForCells; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.findAllArtworksToBeUploadedFromCells; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.mainMenuCommandsForCells; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.positionForRPCRequest; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.removeCellFromList; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.sendRPCs; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.subMenuCommandsForCells; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.transferCellIDsFromCells; +import static com.smartdevicelink.managers.screen.menu.MenuReplaceUtilities.transferCellListenersFromCells; + +import com.livio.taskmaster.Task; +import com.smartdevicelink.managers.CompletionListener; +import com.smartdevicelink.managers.ISdl; +import com.smartdevicelink.managers.file.FileManager; +import com.smartdevicelink.managers.file.MultipleFileCompletionListener; +import com.smartdevicelink.managers.file.filetypes.SdlArtwork; +import com.smartdevicelink.managers.screen.menu.DynamicMenuUpdateAlgorithm.MenuCellState; +import com.smartdevicelink.proxy.RPCRequest; +import com.smartdevicelink.proxy.RPCResponse; +import com.smartdevicelink.proxy.rpc.SdlMsgVersion; +import com.smartdevicelink.proxy.rpc.WindowCapability; +import com.smartdevicelink.proxy.rpc.enums.ImageFieldName; +import com.smartdevicelink.proxy.rpc.enums.MenuLayout; +import com.smartdevicelink.proxy.rpc.enums.TextFieldName; +import com.smartdevicelink.util.DebugTool; + +import org.json.JSONException; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Created by Bilal Alsharifi on 1/20/21. + */ +class MenuReplaceOperation extends Task { + private static final String TAG = "MenuReplaceOperation"; + + private final WeakReference<ISdl> internalInterface; + private final WeakReference<FileManager> fileManager; + private WindowCapability windowCapability; + private List<MenuCell> currentMenu; + private final List<MenuCell> updatedMenu; + private final boolean isDynamicMenuUpdateActive; + private final MenuManagerCompletionListener operationCompletionListener; + private MenuConfiguration menuConfiguration; + + MenuReplaceOperation(ISdl internalInterface, FileManager fileManager, WindowCapability windowCapability, MenuConfiguration menuConfiguration, List<MenuCell> currentMenu, List<MenuCell> updatedMenu, boolean isDynamicMenuUpdateActive, MenuManagerCompletionListener operationCompletionListener) { + super(TAG); + this.internalInterface = new WeakReference<>(internalInterface); + this.fileManager = new WeakReference<>(fileManager); + this.windowCapability = windowCapability; + this.menuConfiguration = menuConfiguration; + this.currentMenu = currentMenu; + this.updatedMenu = updatedMenu; + this.isDynamicMenuUpdateActive = isDynamicMenuUpdateActive; + this.operationCompletionListener = operationCompletionListener; + } + + @Override + public void onExecute() { + start(); + } + + private void start() { + if (getState() == Task.CANCELED) { + return; + } + + updateMenuCells(new CompletionListener() { + @Override + public void onComplete(boolean success) { + finishOperation(success); + } + }); + } + + private void updateMenuCells(final CompletionListener listener) { + addIdsToMenuCells(updatedMenu, parentIdNotFound); + + // Strip the "current menu" and the new menu of properties that are not displayed on the head unit + List<MenuCell> updatedStrippedMenu = cellsWithRemovedPropertiesFromCells(updatedMenu, windowCapability); + List<MenuCell> currentStrippedMenu = cellsWithRemovedPropertiesFromCells(currentMenu, windowCapability); + + // Check if head unit supports cells with duplicate titles + SdlMsgVersion rpcVersion = internalInterface.get().getSdlMsgVersion(); + boolean supportsMenuUniqueness = rpcVersion.getMajorVersion() > 7 || (rpcVersion.getMajorVersion() == 7 && rpcVersion.getMinorVersion() > 0); + + // Generate unique names and ensure that all menus we are tracking have them so that we can properly compare when using the dynamic algorithm + generateUniqueNamesForCells(updatedStrippedMenu, supportsMenuUniqueness); + applyUniqueNamesOnCells(updatedStrippedMenu, updatedMenu); + + DynamicMenuUpdateRunScore runScore; + if (!isDynamicMenuUpdateActive) { + DebugTool.logInfo(TAG, "Dynamic menu update inactive. Forcing the deletion of all old cells and adding all new ones, even if they're the same."); + runScore = DynamicMenuUpdateAlgorithm.compatibilityRunScoreWithOldMenuCells(currentStrippedMenu, updatedStrippedMenu); + } else { + DebugTool.logInfo(TAG, "Dynamic menu update active. Running the algorithm to find the best way to delete / add cells."); + runScore = DynamicMenuUpdateAlgorithm.dynamicRunScoreOldMenuCells(currentStrippedMenu, updatedStrippedMenu); + } + + // If both old and new menu cells are empty, nothing needs to be done. + if (runScore.isEmpty()) { + listener.onComplete(true); + return; + } + + List<MenuCellState> deleteMenuStatus = runScore.getOldStatus(); + List<MenuCellState> addMenuStatus = runScore.getUpdatedStatus(); + + // Drop the cells into buckets based on the run score + final List<MenuCell> cellsToDelete = filterMenuCellsWithStatusList(currentMenu, deleteMenuStatus, MenuCellState.DELETE); + final List<MenuCell> cellsToAdd = filterMenuCellsWithStatusList(updatedMenu, addMenuStatus, MenuCellState.ADD); + + // These lists should ONLY contain KEEPS. These will be used for SubMenu compares + final List<MenuCell> oldKeeps = filterMenuCellsWithStatusList(currentMenu, deleteMenuStatus, MenuCellState.KEEP); + final List<MenuCell> newKeeps = filterMenuCellsWithStatusList(updatedMenu, addMenuStatus, MenuCellState.KEEP); + + // Old kept cells ids need to be moved to the new kept cells so that submenu changes have correct parent ids + transferCellIDsFromCells(oldKeeps, newKeeps); + + // Transfer new cells' listeners to the old cells, which are stored in the current menu + transferCellListenersFromCells(newKeeps, oldKeeps); + + // Upload the Artworks, then we will start updating the main menu + uploadMenuArtworks(new CompletionListener() { + @Override + public void onComplete(boolean success) { + if (getState() == Task.CANCELED) { + return; + } + + if (!success) { + listener.onComplete(false); + return; + } + + updateMenuWithCellsToDelete(cellsToDelete, cellsToAdd, new CompletionListener() { + @Override + public void onComplete(boolean success) { + if (getState() == Task.CANCELED) { + return; + } + + if (!success) { + listener.onComplete(false); + return; + } + + updateSubMenuWithOldKeptCells(oldKeeps, newKeeps, 0, listener); + } + }); + } + }); + } + + private void uploadMenuArtworks(final CompletionListener listener) { + List<SdlArtwork> artworksToBeUploaded = new ArrayList<>(findAllArtworksToBeUploadedFromCells(updatedMenu, fileManager.get(), windowCapability)); + if (artworksToBeUploaded.isEmpty()) { + listener.onComplete(true); + return; + } + + if (fileManager.get() == null) { + listener.onComplete(false); + return; + } + + fileManager.get().uploadArtworks(artworksToBeUploaded, new MultipleFileCompletionListener() { + @Override + public void onComplete(Map<String, String> errors) { + if (errors != null && !errors.isEmpty()) { + DebugTool.logError(TAG, "Error uploading Menu Artworks: " + errors.toString()); + listener.onComplete(false); + } else { + DebugTool.logInfo(TAG, "Menu artwork upload completed, beginning upload of main menu"); + listener.onComplete(true); + } + } + }); + } + + /** + * Takes the main menu cells to delete and add, and deletes the current menu cells, then adds the new menu cells in the correct locations + * + * @param deleteCells The cells that need to be deleted + * @param addCells The cells that need to be added + * @param listener A CompletionListener called when complete + */ + private void updateMenuWithCellsToDelete(List<MenuCell> deleteCells, final List<MenuCell> addCells, final CompletionListener listener) { + sendDeleteMenuCells(deleteCells, new CompletionListener() { + @Override + public void onComplete(boolean success) { + if (getState() == Task.CANCELED) { + return; + } + + sendAddMenuCells(addCells, updatedMenu, new CompletionListener() { + @Override + public void onComplete(boolean success) { + if (!success) { + DebugTool.logError(TAG, "Error Sending Current Menu"); + } + + listener.onComplete(success); + } + }); + } + }); + } + + /** + * Takes the submenu cells that are old keeps and new keeps and determines which cells need to be deleted or added + * + * @param oldKeptCells The old kept cells + * @param newKeptCells The new kept cells + * @param index The index of the main menu to use + * @param listener The listener to call when all submenu updates are complete + */ + private void updateSubMenuWithOldKeptCells(final List<MenuCell> oldKeptCells, final List<MenuCell> newKeptCells, final int index, final CompletionListener listener) { + if (oldKeptCells.isEmpty() || index >= oldKeptCells.size()) { + listener.onComplete(true); + return; + } + + if (oldKeptCells.get(index) != null && oldKeptCells.get(index).isSubMenuCell() && !oldKeptCells.get(index).getSubCells().isEmpty()) { + DynamicMenuUpdateRunScore tempScore = DynamicMenuUpdateAlgorithm.dynamicRunScoreOldMenuCells(oldKeptCells.get(index).getSubCells(), newKeptCells.get(index).getSubCells()); + + // If both old and new menu cells are empty. Then nothing needs to be done. + if (tempScore.isEmpty()) { + // After the first set of submenu cells were added and deleted we must find the next set of sub cells until we loop through all the elements + updateSubMenuWithOldKeptCells(oldKeptCells, newKeptCells, index + 1, listener); + return; + } + + List<MenuCellState> deleteMenuStatus = tempScore.getOldStatus(); + List<MenuCellState> addMenuStatus = tempScore.getUpdatedStatus(); + + final List<MenuCell> cellsToDelete = filterMenuCellsWithStatusList(oldKeptCells.get(index).getSubCells(), deleteMenuStatus, MenuCellState.DELETE); + final List<MenuCell> cellsToAdd = filterMenuCellsWithStatusList(newKeptCells.get(index).getSubCells(), addMenuStatus, MenuCellState.ADD); + + final List<MenuCell> oldSubcellKeeps = filterMenuCellsWithStatusList(oldKeptCells.get(index).getSubCells(), deleteMenuStatus, MenuCellState.KEEP); + final List<MenuCell> newSubcellKeeps = filterMenuCellsWithStatusList(newKeptCells.get(index).getSubCells(), addMenuStatus, MenuCellState.KEEP); + + transferCellListenersFromCells(newSubcellKeeps, oldSubcellKeeps); + + sendDeleteMenuCells(cellsToDelete, new CompletionListener() { + @Override + public void onComplete(boolean success) { + if (getState() == Task.CANCELED) { + return; + } + + if (!success) { + listener.onComplete(false); + } + + sendAddMenuCells(cellsToAdd, newKeptCells.get(index).getSubCells(), new CompletionListener() { + @Override + public void onComplete(boolean success) { + if (getState() == Task.CANCELED) { + return; + } + + if (!success) { + listener.onComplete(false); + } + + // After the first set of submenu cells were added and deleted we must find the next set of sub cells until we loop through all the elements + updateSubMenuWithOldKeptCells(oldKeptCells, newKeptCells, index + 1, listener); + } + }); + } + }); + } else { + // There are no sub cells, we can skip to the next index. + updateSubMenuWithOldKeptCells(oldKeptCells, newKeptCells, index + 1, listener); + } + } + + /** + * Send Delete RPCs for given menu cells + * + * @param deleteMenuCells The menu cells to be deleted + * @param listener A CompletionListener called when the RPCs are finished with an error if any failed + */ + private void sendDeleteMenuCells(List<MenuCell> deleteMenuCells, final CompletionListener listener) { + if (deleteMenuCells == null || deleteMenuCells.isEmpty()) { + listener.onComplete(true); + return; + } + + List<RPCRequest> deleteMenuCommands = deleteCommandsForCells(deleteMenuCells); + sendRPCs(deleteMenuCommands, internalInterface.get(), new SendingRPCsCompletionListener() { + @Override + public void onComplete(boolean success, Map<RPCRequest, String> errors) { + if (!success) { + DebugTool.logWarning(TAG, "Unable to delete all old menu commands. " + convertErrorsMapToString(errors)); + } else { + DebugTool.logInfo(TAG, "Finished deleting old menu"); + } + listener.onComplete(success); + } + + @Override + public void onResponse(RPCRequest request, RPCResponse response) { + if (response.getSuccess()) { + // Find the id of the successful request and remove it from the current menu list wherever it may have been + int commandId = commandIdForRPCRequest(request); + removeCellFromList(currentMenu, commandId); + } + } + }); + } + + /** + * Send Add RPCs for given new menu cells compared to old menu cells + * + * @param addMenuCells The new menu cells we want displayed + * @param fullMenu The full menu from which the addMenuCells come. This allows us to grab the positions from that menu for the new cells + * @param listener A CompletionListener called when the RPCs are finished with an error if any failed + */ + private void sendAddMenuCells(final List<MenuCell> addMenuCells, final List<MenuCell> fullMenu, final CompletionListener listener) { + if (addMenuCells == null || addMenuCells.isEmpty()) { + DebugTool.logInfo(TAG, "There are no cells to update."); + listener.onComplete(true); + return; + } + + MenuLayout defaultSubmenuLayout = menuConfiguration != null ? menuConfiguration.getSubMenuLayout() : null; + + // RPCs for cells on the main menu level. They could be AddCommands or AddSubMenus depending on whether the cell has child cells or not. + final List<RPCRequest> mainMenuCommands = mainMenuCommandsForCells(addMenuCells, fileManager.get(), fullMenu, windowCapability, defaultSubmenuLayout); + + // RPCs for cells on the second menu level (one level deep). They could be AddCommands or AddSubMenus. + final List<RPCRequest> subMenuCommands = subMenuCommandsForCells(addMenuCells, fileManager.get(), windowCapability, defaultSubmenuLayout); + + sendRPCs(mainMenuCommands, internalInterface.get(), new SendingRPCsCompletionListener() { + @Override + public void onComplete(boolean success, Map<RPCRequest, String> errors) { + if (!success) { + DebugTool.logError(TAG, "Failed to send main menu commands. " + convertErrorsMapToString(errors)); + listener.onComplete(false); + return; + } else if (subMenuCommands.isEmpty()) { + DebugTool.logInfo(TAG, "Finished sending new cells"); + listener.onComplete(true); + return; + } + + sendRPCs(subMenuCommands, internalInterface.get(), new SendingRPCsCompletionListener() { + @Override + public void onComplete(boolean success, Map<RPCRequest, String> errors) { + if (!success) { + DebugTool.logError(TAG, "Failed to send sub menu commands. " + convertErrorsMapToString(errors)); + } else { + DebugTool.logInfo(TAG, "Finished sending new cells"); + } + listener.onComplete(success); + } + + @Override + public void onResponse(RPCRequest request, RPCResponse response) { + if (response.getSuccess()) { + // Find the id of the successful request and add it from the current menu list wherever it needs to be + int commandId = commandIdForRPCRequest(request); + int position = positionForRPCRequest(request); + addCellWithCellId(commandId, position, addMenuCells, currentMenu); + } + } + }); + } + + @Override + public void onResponse(RPCRequest request, RPCResponse response) { + if (response.getSuccess()) { + // Find the id of the successful request and add it from the current menu list wherever it needs to be + int commandId = commandIdForRPCRequest(request); + int position = positionForRPCRequest(request); + addCellWithCellId(commandId, position, addMenuCells, currentMenu); + } + } + }); + } + + private List<MenuCell> filterMenuCellsWithStatusList(List<MenuCell> menuCells, List<MenuCellState> statusList, MenuCellState menuCellState) { + List<MenuCell> filteredCells = new ArrayList<>(); + for (int index = 0; index < statusList.size(); index++) { + if (statusList.get(index).equals(menuCellState)) { + filteredCells.add(menuCells.get(index)); + } + } + return filteredCells; + } + + private String convertErrorsMapToString(Map<RPCRequest, String> errors) { + if (errors == null) { + return null; + } + StringBuilder stringBuilder = new StringBuilder(); + for (RPCRequest request : errors.keySet()) { + stringBuilder.append(errors.get(request)); + stringBuilder.append(System.getProperty("line.separator")); + try { + stringBuilder.append(request.serializeJSON().toString(4)); + } catch (JSONException e) { + e.printStackTrace(); + } + stringBuilder.append(System.getProperty("line.separator")); + } + return stringBuilder.toString(); + } + + List<MenuCell> cellsWithRemovedPropertiesFromCells(List<MenuCell> cells, WindowCapability windowCapability) { + if (cells == null) { + return null; + } + + List<MenuCell> removePropertiesClone = cloneMenuCellsList(cells); + + for (MenuCell cell : removePropertiesClone) { + // Strip away fields that cannot be used to determine uniqueness visually including fields not supported by the HMI + cell.setVoiceCommands(null); + + // Don't check ImageFieldName.subMenuIcon because it was added in 7.0 when the feature was added in 5.0. + // Just assume that if cmdIcon is not available, the submenu icon is not either. + if (!hasImageFieldOfName(windowCapability, ImageFieldName.cmdIcon)) { + cell.setIcon(null); + } + + // Check for subMenu fields supported + if (cell.isSubMenuCell()) { + if (!hasTextFieldOfName(windowCapability, TextFieldName.menuSubMenuSecondaryText)) { + cell.setSecondaryText(null); + } + if (!hasTextFieldOfName(windowCapability, TextFieldName.menuSubMenuTertiaryText)) { + cell.setTertiaryText(null); + } + if (!hasImageFieldOfName(windowCapability, ImageFieldName.menuSubMenuSecondaryImage)) { + cell.setSecondaryArtwork(null); + } + cell.setSubCells(cellsWithRemovedPropertiesFromCells(cell.getSubCells(), windowCapability)); + } else { + if (!hasTextFieldOfName(windowCapability, TextFieldName.menuCommandSecondaryText)) { + cell.setSecondaryText(null); + } + if (!hasTextFieldOfName(windowCapability, TextFieldName.menuCommandTertiaryText)) { + cell.setTertiaryText(null); + } + if (!hasImageFieldOfName(windowCapability, ImageFieldName.menuCommandSecondaryImage)) { + cell.setSecondaryArtwork(null); + } + } + } + return removePropertiesClone; + } + + private void generateUniqueNamesForCells(List<MenuCell> menuCells, boolean supportsMenuUniqueness) { + if (menuCells == null) { + return; + } + + // Tracks how many of each cell primary text there are so that we can append numbers to make each unique as necessary + HashMap<String, Integer> dictCounter = new HashMap<>(); + + for (MenuCell cell : menuCells) { + String key = supportsMenuUniqueness ? String.valueOf(cell.hashCode()) : cell.getTitle(); + Integer counter = dictCounter.get(key); + + if (counter != null) { + dictCounter.put(key, ++counter); + cell.setUniqueTitle(cell.getTitle() + " (" + counter + ")"); + } else { + dictCounter.put(key, 1); + cell.setUniqueTitle(cell.getTitle()); + } + + if (cell.isSubMenuCell() && !cell.getSubCells().isEmpty()) { + generateUniqueNamesForCells(cell.getSubCells(), supportsMenuUniqueness); + } + } + } + + private void applyUniqueNamesOnCells(List<MenuCell> fromMenuCells, List<MenuCell> toMenuCells) { + if (fromMenuCells.size() != toMenuCells.size()) { + return; + } + + for (int i = 0; i < fromMenuCells.size(); i++) { + toMenuCells.get(i).setUniqueTitle(fromMenuCells.get(i).getUniqueTitle()); + if (fromMenuCells.get(i).isSubMenuCell() && !fromMenuCells.get(i).getSubCells().isEmpty()) { + applyUniqueNamesOnCells(fromMenuCells.get(i).getSubCells(), toMenuCells.get(i).getSubCells()); + } + } + } + + void setMenuConfiguration(MenuConfiguration menuConfiguration) { + this.menuConfiguration = menuConfiguration; + } + + void setCurrentMenu(List<MenuCell> currentMenuCells) { + this.currentMenu = currentMenuCells; + } + + void setWindowCapability(WindowCapability windowCapability) { + this.windowCapability = windowCapability; + } + + private void finishOperation(boolean success) { + if (operationCompletionListener != null) { + operationCompletionListener.onComplete(success, currentMenu); + } + onFinished(); + } +} |