/* * Copyright 2022 Google LLC * * 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.google.android.libraries.mobiledatadownload.testing; import android.accounts.Account; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import com.google.mobiledatadownload.internal.MetadataProto.GroupKey; import com.google.android.libraries.mobiledatadownload.AddFileGroupRequest; import com.google.android.libraries.mobiledatadownload.DownloadException; import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode; import com.google.android.libraries.mobiledatadownload.DownloadFileGroupRequest; import com.google.android.libraries.mobiledatadownload.GetFileGroupRequest; import com.google.android.libraries.mobiledatadownload.GetFileGroupsByFilterRequest; import com.google.android.libraries.mobiledatadownload.ImportFilesRequest; import com.google.android.libraries.mobiledatadownload.MobileDataDownload; import com.google.android.libraries.mobiledatadownload.ReadDataFileGroupRequest; import com.google.android.libraries.mobiledatadownload.RemoveFileGroupRequest; import com.google.android.libraries.mobiledatadownload.RemoveFileGroupsByFilterRequest; import com.google.android.libraries.mobiledatadownload.RemoveFileGroupsByFilterResponse; import com.google.android.libraries.mobiledatadownload.SingleFileDownloadRequest; import com.google.android.libraries.mobiledatadownload.TaskScheduler.ConstraintOverrides; import com.google.android.libraries.mobiledatadownload.UsageEvent; import com.google.android.libraries.mobiledatadownload.account.AccountUtil; import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage; import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri; import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener; import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures; import com.google.common.base.Optional; import com.google.common.collect.HashBasedTable; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.collect.Table; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.mobiledatadownload.ClientConfigProto.ClientFile; import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup; import com.google.mobiledatadownload.DownloadConfigProto.DataFile; import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.EnumMap; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executor; /** * Fake implementation of {@link MobileDataDownload}. * *

FakeMobileDataDownload is thread-safe. All the apis part of MobileDataDownload interface can * be invoked from multiple threads safely. Thread safety for helper functions (like setUpFileGroup, * setThrowable, setThrowableOnFileGroup, get*Params apis etc) is not provided. To avoid race * conditions, all the set up functions should be invoked at the beginning of the test before * testing the business logic and get*Params apis should be invoked only after all the pending tasks * are done. Refer to wait for all the pending background asynchronous tasks to complete. */ public final class FakeMobileDataDownload implements MobileDataDownload { // private static final GoogleLogger logger = GoogleLogger.forEnclosingClass(); private final List addFileGroupParamsList = new ArrayList<>(); private final List downloadedFileGroupList = new ArrayList<>(); private final List getFileGroupParamsList = new ArrayList<>(); private final List handleTaskParamsList = new ArrayList<>(); private final List pendingFileGroupList = new ArrayList<>(); private final Map throwableMap = new EnumMap<>(MethodType.class); private final Table methodTypeGroupKeyToThrowableTable = HashBasedTable.create(); private final List downloadFileGroupParamsList = new ArrayList<>(); private final List downloadFileGroupWithForegroundServiceParamsList = new ArrayList<>(); private final List removeFileGroupParamsList = new ArrayList<>(); private final Map remoteFilesMap = new HashMap<>(); private final Optional storageOptional; private final Executor sequentialControlExecutor; /** Enum for different MDD methods. Used to set Throwable. */ public enum MethodType { ADD_FILE_GROUP, GET_FILE_GROUP, REMOVE_FILE_GROUP, DOWNLOAD_FILE, DOWNLOAD_FILE_FOREGROUND, } /** {@code storageOptional} must be present to download files set through setUpRemoteFile. */ FakeMobileDataDownload(Optional storageOptional, Executor executor) { this.storageOptional = storageOptional; this.sequentialControlExecutor = MoreExecutors.newSequentialExecutor(executor); } public static FakeMobileDataDownload createFakeMddWithFileStorage( SynchronousFileStorage storage) { return new FakeMobileDataDownload( Optional.of(storage), MoreExecutors.newSequentialExecutor(MoreExecutors.directExecutor())); } private static List getMatchingFileGroups( GroupKey groupKey, List fileGroupList) { // logger.atConfig().log("#getMatchingFileGroups: %s, %s", groupKey, fileGroupList); List filteredFileGroupList = new ArrayList<>(); for (ClientFileGroup fileGroup : fileGroupList) { // Check for group name match. if (groupKey.hasGroupName() && !groupKey.getGroupName().equals(fileGroup.getGroupName())) { continue; } // Check for owner_package match. if (groupKey.hasOwnerPackage() && !groupKey.getOwnerPackage().equals(fileGroup.getOwnerPackage())) { continue; } // Check for account match. if (groupKey.hasAccount() && !groupKey.getAccount().equals(fileGroup.getAccount())) { continue; } // Check for variant id match. if (groupKey.hasVariantId() && !groupKey.getVariantId().equals(fileGroup.getVariantId())) { continue; } filteredFileGroupList.add(fileGroup); } return filteredFileGroupList; } /** * Sets {@link ClientFileGroup} instance to use in getFileGroup, getFileGroupsByFilter and * downloadFileGroup methods. * *

getFileGroup, getFileGroupsByFilter, downloadFileGroup methods will look for a match in all * the file groups set using this api before returning the result. * * @param clientFileGroup ClientFileGroup instance. * @param downloaded if true, assumes the ClientFileGroup instance is downloaded, else download is * pending. */ public void setUpFileGroup(ClientFileGroup clientFileGroup, boolean downloaded) { if (downloaded) { downloadedFileGroupList.add( clientFileGroup.toBuilder().setStatus(ClientFileGroup.Status.DOWNLOADED).build()); } else { pendingFileGroupList.add( clientFileGroup.toBuilder().setStatus(ClientFileGroup.Status.PENDING).build()); } } /** * Returns the list of parameters that addFileGroup method was invocated with. * * @return List of all the requests of type {@link AddFileGroupRequest} that addFileGroup method * was called with. */ public ImmutableList getAddFileGroupParamsList() { return ImmutableList.copyOf(addFileGroupParamsList); } /** * Returns the list of parameters that removeFileGroup method was invocated with. * * @return List of all the requests of type {@link RemoveFileGroupRequest} that removeFileGroup * method was called with. */ public ImmutableList getRemoveFileGroupParamsList() { return ImmutableList.copyOf(removeFileGroupParamsList); } /** * Returns the list of parameters that downloadFileGroup method was invocated with. * * @return List of all the requests of type {@link DownloadFileGroupRequest} that * downloadFileGroup method was called with. */ public ImmutableList getDownloadFileGroupParamsList() { return ImmutableList.copyOf(downloadFileGroupParamsList); } /** * Returns the list of parameters that downloadFileGroupWithForegroundService method was invocated * with. * * @return List of all the requests of type {@link DownloadFileGroupRequest} that * downloadFileGroup method was called with. */ public ImmutableList getDownloadFileGroupWithForegroundServiceParamsList() { return ImmutableList.copyOf(downloadFileGroupWithForegroundServiceParamsList); } /** * Returns the list of parameters that getFileGroup method was invocated with. * * @return List of all the requests of type {@link GetFileGroupRequest} that getFileGroup method * was called with. */ public ImmutableList getGetFileGroupParamsList() { return ImmutableList.copyOf(getFileGroupParamsList); } /** Returns the list of parameters that handleTask method was invocated with. */ public ImmutableList getHandleTaskParamsList() { return ImmutableList.copyOf(handleTaskParamsList); } /** * Sets {@code throwable} to throw on invocation of a method identified by {@code methodType} * * @param methodType enum to identify method. * @param throwable Throwable to throw on method's invocation. */ public void setThrowable(MethodType methodType, Throwable throwable) { this.throwableMap.put(methodType, throwable); } /** * Sets {@code throwable} to throw on invocation of method identified by {@code methodType} when * the properties set using {@code groupName}, {@code variantIdOptional}, {@code accountOptional} * matches with the filegroup on which the method is invoked. * * @param methodType enum to identify method. * @param groupName Name of the file group. * @param accountOptional Account of the file group. Setting this is optional. * @param variantIdOptional Variant Id of the file group. Setting this is optional. * @param throwable Throwable to throw. *

If throwable is set using both #setThrowable and #setThrowableOnFileGroup for a method, * priority is given to throwable set through the latter. */ public void setThrowableOnFileGroup( MethodType methodType, String groupName, Optional accountOptional, Optional variantIdOptional, Throwable throwable) { if (methodType != MethodType.GET_FILE_GROUP) { throw new IllegalArgumentException( "setThrowableOnFileGroup is currently only supported for getFileGroup method."); } GroupKey.Builder groupKeyBuilder = GroupKey.newBuilder(); groupKeyBuilder.setGroupName(groupName); if (accountOptional.isPresent()) { groupKeyBuilder.setAccount(AccountUtil.serialize(accountOptional.get())); } if (variantIdOptional.isPresent()) { groupKeyBuilder.setVariantId(variantIdOptional.get()); } methodTypeGroupKeyToThrowableTable.put(methodType, groupKeyBuilder.build(), throwable); } /** * Set file corresponding to a url. * *

Used by downloadFile and downloadFileWithForegroundService. If the * SingleFileDownloadRequest#urlToDownload matches any of the set url, file is created at * SingleFileDownloadRequest#destinationFileUri with the corresponding set content. * *

Setting content for an already existing url will replace the existing contents. */ public void setUpRemoteFile(String urlToDownload, byte[] content) { // NOTE: If a client is using AssetFileBackend, then the corresponding test assets can be // used here if the parameter type is Uri instead of byte[]. // Note: Here byte[] will be stored in memory. Uri avoids this and supports large files cleanly. remoteFilesMap.put(urlToDownload, content); } @Override public ListenableFuture addFileGroup(AddFileGroupRequest addFileGroupRequest) { // logger.atInfo().log("#addFileGroup: %s", addFileGroupRequest); Throwable addFileGroupThrowable = throwableMap.get(MethodType.ADD_FILE_GROUP); if (addFileGroupThrowable != null) { return Futures.immediateFailedFuture(addFileGroupThrowable); } addFileGroupParamsList.add(addFileGroupRequest); // Let addFileGroup induce realistic behavior. // Wrap in background executor because this might do disk reads. return PropagatedFutures.submitAsync( () -> { setUpFileGroup(toClientFileGroup(addFileGroupRequest), false); return Futures.immediateFuture(true); }, sequentialControlExecutor); } private ClientFileGroup toClientFileGroup(AddFileGroupRequest addFileGroupRequest) { ClientFileGroup.Builder clientFileGroupBuilder = ClientFileGroup.newBuilder() .setGroupName(addFileGroupRequest.dataFileGroup().getGroupName()); if (addFileGroupRequest.accountOptional().isPresent()) { clientFileGroupBuilder.setAccount( AccountUtil.serialize(addFileGroupRequest.accountOptional().get())); } if (addFileGroupRequest.dataFileGroup().hasOwnerPackage()) { clientFileGroupBuilder.setOwnerPackage(addFileGroupRequest.dataFileGroup().getOwnerPackage()); } if (addFileGroupRequest.variantIdOptional().isPresent()) { clientFileGroupBuilder.setVariantId(addFileGroupRequest.variantIdOptional().get()); } for (DataFile dataFile : addFileGroupRequest.dataFileGroup().getFileList()) { ClientFile.Builder clientFileBuilder = ClientFile.newBuilder().setFileId(dataFile.getFileId()); if (dataFile.hasUrlToDownload()) { String urlToDownload = dataFile.getUrlToDownload(); clientFileBuilder.setFileUri(getMobstoreUriForRemoteFile(urlToDownload).toString()); maybeSetUpFileAtUri(urlToDownload); } clientFileGroupBuilder.addFile(clientFileBuilder); } return clientFileGroupBuilder.build(); } private void maybeSetUpFileAtUri(String urlToDownload) { if (storageOptional.isPresent() && remoteFilesMap.containsKey(urlToDownload)) { try { Uri mobstoreUri = getMobstoreUriForRemoteFile(urlToDownload); storageOptional .get() .open(mobstoreUri, WriteStreamOpener.create()) .write(remoteFilesMap.get(urlToDownload)); // logger.atInfo().log( // "Writing file for URL %s to Mobstore URI: %s", urlToDownload, mobstoreUri); } catch (IOException e) { // logger.atSevere().withCause(e).log("Mobstore file write failed"); } } else { // logger.atConfig().log( // "No file set for %s. Consider using #setUpRemoteFile if a download is requested.", // urlToDownload); } } private static Uri getMobstoreUriForRemoteFile(String urlToDownload) { return AndroidUri.builder(ApplicationProvider.getApplicationContext()) .setModule("fakemddtest") .setRelativePath(String.valueOf(Integer.valueOf(urlToDownload.hashCode()))) .build(); } @Override public ListenableFuture removeFileGroup(RemoveFileGroupRequest removeFileGroupRequest) { Throwable removeFileGroupThrowable = throwableMap.get(MethodType.REMOVE_FILE_GROUP); if (removeFileGroupThrowable != null) { return Futures.immediateFailedFuture(removeFileGroupThrowable); } removeFileGroupParamsList.add(removeFileGroupRequest); return PropagatedFutures.submitAsync( () -> Futures.immediateFuture(true), sequentialControlExecutor); } @Override public ListenableFuture removeFileGroupsByFilter( RemoveFileGroupsByFilterRequest removeFileGroupsByFilterRequest) { return PropagatedFutures.submitAsync( () -> Futures.immediateFuture( RemoveFileGroupsByFilterResponse.newBuilder().setRemovedFileGroupsCount(0).build()), sequentialControlExecutor); } @Override public ListenableFuture readDataFileGroup( ReadDataFileGroupRequest readDataFileGroupRequest) { return Futures.immediateFailedFuture(new UnsupportedOperationException()); } @Override public ListenableFuture getFileGroup(GetFileGroupRequest getFileGroupRequest) { // Construct GroupKey from getFileGroupRequest. GroupKey.Builder groupKeyBuilder = GroupKey.newBuilder(); groupKeyBuilder.setGroupName(getFileGroupRequest.groupName()); if (getFileGroupRequest.accountOptional().isPresent()) { groupKeyBuilder.setAccount( AccountUtil.serialize(getFileGroupRequest.accountOptional().get())); } if (getFileGroupRequest.variantIdOptional().isPresent()) { groupKeyBuilder.setVariantId(getFileGroupRequest.variantIdOptional().get()); } GroupKey groupKey = groupKeyBuilder.build(); // Throw exception if a throwable is set. Throwable getFileGroupThrowable = methodTypeGroupKeyToThrowableTable.get(MethodType.GET_FILE_GROUP, groupKey); if (getFileGroupThrowable == null) { getFileGroupThrowable = throwableMap.get(MethodType.GET_FILE_GROUP); } if (getFileGroupThrowable != null) { return Futures.immediateFailedFuture(getFileGroupThrowable); } getFileGroupParamsList.add(getFileGroupRequest); return PropagatedFutures.submitAsync( () -> { List fileGroupList = getMatchingFileGroups(groupKeyBuilder.build(), downloadedFileGroupList); return Futures.immediateFuture(Iterables.getFirst(fileGroupList, null)); }, sequentialControlExecutor); } @Override public ListenableFuture> getFileGroupsByFilter( GetFileGroupsByFilterRequest getFileGroupsByFilterRequest) { return PropagatedFutures.submitAsync( () -> { List allFileGroups = new ArrayList<>(downloadedFileGroupList); allFileGroups.addAll(pendingFileGroupList); if (getFileGroupsByFilterRequest.includeAllGroups()) { return Futures.immediateFuture(ImmutableList.copyOf(allFileGroups)); } GroupKey.Builder groupKeyBuilder = GroupKey.newBuilder(); if (getFileGroupsByFilterRequest.groupNameOptional().isPresent()) { groupKeyBuilder.setGroupName(getFileGroupsByFilterRequest.groupNameOptional().get()); } if (getFileGroupsByFilterRequest.accountOptional().isPresent()) { groupKeyBuilder.setAccount( AccountUtil.serialize(getFileGroupsByFilterRequest.accountOptional().get())); } return Futures.immediateFuture( ImmutableList.copyOf(getMatchingFileGroups(groupKeyBuilder.build(), allFileGroups))); }, sequentialControlExecutor); } @Override public ListenableFuture importFiles(ImportFilesRequest importFilesRequest) { return Futures.immediateVoidFuture(); } /** * If a file is set using setUpRemoteFile for {@code urlToDownload}, the contents will be copied * to {@code destinationFileUri}. */ private void downloadFileIfSet(String urlToDownload, Uri destinationFileUri) throws IOException { if (!remoteFilesMap.containsKey(urlToDownload)) { // logger.atWarning().log( // "No file set for %s using setUpRemoteFile. Download request is a no-op.", urlToDownload); return; } if (!storageOptional.isPresent()) { // logger.atSevere().log("Storage not set."); return; } try (OutputStream out = storageOptional.get().open(destinationFileUri, WriteStreamOpener.create())) { out.write(remoteFilesMap.get(urlToDownload)); } } /** * Copies file to the singleFileDownloadRequest#destinationFileUri if set using {@code * setUpRemoteFile} * *

Storage needs to be present to copy the file to destinationFileUri and corresponding backend * needs to be added to the storage. Throws UnsupportedFileStorageOperation if corresponding * backend is not set. */ @Override public ListenableFuture downloadFile(SingleFileDownloadRequest singleFileDownloadRequest) { Throwable throwable = throwableMap.get(MethodType.DOWNLOAD_FILE); if (throwable != null) { return Futures.immediateFailedFuture(throwable); } return PropagatedFutures.submitAsync( () -> { try { downloadFileIfSet( singleFileDownloadRequest.urlToDownload(), singleFileDownloadRequest.destinationFileUri()); } catch (IOException e) { return Futures.immediateFailedFuture(e); } return Futures.immediateVoidFuture(); }, sequentialControlExecutor); } @Override public ListenableFuture downloadFileGroup( DownloadFileGroupRequest downloadFileGroupRequest) { // logger.atInfo().log("#downloadFileGroup: %s", downloadFileGroupRequest); downloadFileGroupParamsList.add(downloadFileGroupRequest); return PropagatedFutures.submitAsync( () -> downloadFileGroupInternal(downloadFileGroupRequest), sequentialControlExecutor); } @Override public ListenableFuture downloadFileGroupWithForegroundService( DownloadFileGroupRequest downloadFileGroupRequest) { // logger.atInfo().log("#downloadFileGroupWithForegroundService: %s", downloadFileGroupRequest); downloadFileGroupWithForegroundServiceParamsList.add(downloadFileGroupRequest); return PropagatedFutures.submitAsync( () -> downloadFileGroupInternal(downloadFileGroupRequest), sequentialControlExecutor); } private ListenableFuture downloadFileGroupInternal( DownloadFileGroupRequest downloadFileGroupRequest) { // logger.atConfig().log("#downloadFileGroupInternal: %s", downloadFileGroupRequest); GroupKey.Builder groupKeyBuilder = GroupKey.newBuilder(); groupKeyBuilder.setGroupName(downloadFileGroupRequest.groupName()); if (downloadFileGroupRequest.accountOptional().isPresent()) { groupKeyBuilder.setAccount( AccountUtil.serialize(downloadFileGroupRequest.accountOptional().get())); } if (downloadFileGroupRequest.variantIdOptional().isPresent()) { groupKeyBuilder.setVariantId(downloadFileGroupRequest.variantIdOptional().get()); } GroupKey groupKey = groupKeyBuilder.build(); List fileGroupList = getMatchingFileGroups(groupKey, downloadedFileGroupList); if (!fileGroupList.isEmpty()) { return Futures.immediateFuture(fileGroupList.get(0)); } fileGroupList = getMatchingFileGroups(groupKey, pendingFileGroupList); // If there is no match found in downloaded list, look for in pending list and update the // status. if (!fileGroupList.isEmpty()) { ClientFileGroup fileGroup = fileGroupList.get(0); ClientFileGroup downloadedFileGroup = fileGroup.toBuilder().setStatus(ClientFileGroup.Status.DOWNLOADED).build(); pendingFileGroupList.remove(fileGroup); downloadedFileGroupList.add(downloadedFileGroup); return Futures.immediateFuture(downloadedFileGroup); } return Futures.immediateFailedFuture( DownloadException.builder() .setDownloadResultCode(DownloadResultCode.GROUP_NOT_FOUND_ERROR) .build()); } /** * Copies file to the singleFileDownloadRequest#destinationFileUri if set using {@code * setUpRemoteFile} * *

Storage needs to present to copy the file to destinationFileUri and corresponding backend * needs to be added to the storage. Throws UnsupportedFileStorageOperation if corresponding * backend is not set. */ @Override public ListenableFuture downloadFileWithForegroundService( SingleFileDownloadRequest singleFileDownloadRequest) { Throwable throwable = throwableMap.get(MethodType.DOWNLOAD_FILE_FOREGROUND); if (throwable != null) { return Futures.immediateFailedFuture(throwable); } return PropagatedFutures.submitAsync( () -> { try { downloadFileIfSet( singleFileDownloadRequest.urlToDownload(), singleFileDownloadRequest.destinationFileUri()); } catch (IOException e) { return Futures.immediateFailedFuture(e); } return Futures.immediateVoidFuture(); }, sequentialControlExecutor); } @Override public void cancelForegroundDownload(String downloadKey) {} @Override public ListenableFuture maintenance() { return Futures.immediateVoidFuture(); } @Override public ListenableFuture collectGarbage() { return Futures.immediateVoidFuture(); } @Override public void schedulePeriodicTasks() {} @Override public ListenableFuture schedulePeriodicBackgroundTasks() { return Futures.immediateVoidFuture(); } @Override public ListenableFuture schedulePeriodicBackgroundTasks( Optional> constraintOverridesMap) { return Futures.immediateVoidFuture(); } @Override public ListenableFuture cancelPeriodicBackgroundTasks() { return Futures.immediateVoidFuture(); } @Override public ListenableFuture handleTask(String tag) { handleTaskParamsList.add(tag); return Futures.immediateVoidFuture(); } @Override public ListenableFuture clear() { return Futures.immediateVoidFuture(); } @Override public String getDebugInfoAsString() { return ""; } @Override public ListenableFuture reportUsage(UsageEvent usageEvent) { return Futures.immediateVoidFuture(); } }