// Kofax Custom UI Control Classes // Copyright (c) 2017. All rights reserved. // Kofax Confidential. // Unauthorized use, duplication, or distribution, or disclosure is strictly prohibited. // package com.kofax.mobile.sdk.extract.id; import android.content.Context; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.MemoryFile; import com.google.gson.Gson; import com.kofax.R; import com.kofax.kmc.kut.utilities.CertificateValidatorListener; import com.kofax.kmc.kut.utilities.SdkVersion; import com.kofax.kmc.kut.utilities.error.ErrorInfo; import com.kofax.kmc.kut.utilities.error.KmcRuntimeException; import com.kofax.mobile.sdk._internal.KeepProguard; import com.kofax.mobile.sdk._internal.dagger.Injector; import com.kofax.mobile.sdk.extract.id.bundle.IBundleCacheProvider; import com.kofax.mobile.sdk.extract.id.bundle.ZipInputStreamBundle; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import javax.inject.Inject; import bolts.Capture; import bolts.Continuation; import bolts.Task; import okhttp3.Call; import okhttp3.Callback; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; /** * A reference implementation of {@link IProjectProvider} that downloads assets from a * Kofax Quick Updater service instance *

* Note: it is required for the app or user to have * android.permission.ACCESS_NETWORK_STATE permission before calling any method * in this class */ @SuppressWarnings({"TryFinallyCanBeTryWithResources", "unused"}) @KeepProguard public class ServerProjectProvider implements IProjectProvider { private static final String VARIANTS_LIST = "VariantsList.json"; private static final String HIGHEST_VERSION_SEGMENT = "GetHighestVersion"; private static final String PROJECT_SEGMENT = "getProject"; private static final String VARIANT_SEGMENT = "getVariant"; private static final String SDK_VERSION_PARAMETER = "sdkVersion"; private static final String VERSION_PARAMETER = "version"; private static final String PROJECT_PARAMETER = "project"; private static final String VARIANT_PARAMETER = "variant"; private static final Object CLIENT_LOCK = new Object(); private final List _bulkDownloads = new CopyOnWriteArrayList<>(); private final Map> _projectHighestVersionMap = new HashMap<>(); private final String _baseUrl; private final String _noNetworkException; private final String _cannotParseException; @Inject ConnectivityManager _connectivityManager; @Inject IBundleCacheProvider _cacheProvider; /** * Create a new ServerProjectProvider using the Kofax Quick Updater server instance at the given * base URL. * * @param ctx This context * @param baseUrl The Kofax Quick Updater instance URL */ @SuppressWarnings("unused") public ServerProjectProvider( Context ctx, String baseUrl) throws MalformedURLException { /** * Unfortunately, because of this constructor, we cannot check the validity of the * cache provider and connectivity parameters. */ this(ctx, null, baseUrl, null); Injector.getInjector(ctx).inject(this); } /** * Create a new ServerProjectProvider using a custom cache provider, using the Kofax Quick Updater * server instance at the given base URL. * * @param ctx This context * @param cacheProvider The cache provider * @param baseUrl The Kofax Quick Updater instance URL * @param connectivityManager A connectivity manager for checking network status */ @SuppressWarnings("unused") public ServerProjectProvider( Context ctx, IBundleCacheProvider cacheProvider, String baseUrl, ConnectivityManager connectivityManager) throws MalformedURLException { if (ctx == null) { throw new KmcRuntimeException(ErrorInfo.KMC_GN_ILLEGAL_ARGUMENT_EXCEPTION); } if (!baseUrl.endsWith("/")) { baseUrl += "/"; } _baseUrl = baseUrl; if (HttpUrl.parse(_baseUrl) == null) { throw new MalformedURLException(); } _noNetworkException = ctx.getString(R.string.ProjectProvider_NoNetworkConnection); _cannotParseException = ctx.getString(R.string.ProjectProvider_UnparsableError); _cacheProvider = cacheProvider; _connectivityManager = connectivityManager; } /** * Sets the SSL certificate validator listener. *

* The listener receives certificate validation challenges in response to * an authentication request from the Kofax Quick Updater instance. * * @param certificateValidatorListener * @see CertificateValidatorListener */ public void setCertificateValidatorListener(CertificateValidatorListener certificateValidatorListener) { synchronized (CLIENT_LOCK) { HttpClientHolder.get().setCertificateValidatorListener(_baseUrl, certificateValidatorListener); } } /** * Change the connection timeout. *

* This parameter corresponds to all connection timeouts: connect, read and write. If any * operation exceeds the timeout, the process is cancelled, and an exception will be raised. *

* Note: This sets the timeout for new connections. A value of 0 means no timeout, * otherwise values must be between 1 and Integer#MAX_VALUE when converted to * milliseconds. * * @param duration The amount of the timeout * @param timeUnit The time unit associated with the duration * @throws IllegalArgumentException */ public void setRequestTimeout(long duration, TimeUnit timeUnit) { synchronized (CLIENT_LOCK) { HttpClientHolder.get().setTimeOut(duration, timeUnit); } } /** * This implementation keeps a map of project names to a map of sdk versions to model versions. * If the map contains an entry for the key pair {projectName, sdkVersion}, that * value is returned, and no work is done. *

* If no entry exists, the network connection is checked. If a network connection exists, a * server call is attempted. If that value is returned, it is stored in the map and returned. *

* If a server response is not acquired, local cached is checked. If a local version exists, * that version is stored in the map and returned. *

* If no map entry exists, no network connection exists or a server call fails, and finally no * cache exists, a {@link ProjectProviderException} is thrown with a message indicating work * cannot continue. * * @param projectName The name of the project * @param sdkVersion The version of the SDK that must be compatible * @param listener A completion listener. If this is null, no work will be done. */ @Override public void getHighestVersion( final String projectName, final String sdkVersion, final ICompletionListener listener) { if (listener != null) { getHighestVersionAsync(projectName, sdkVersion, false) .continueWith(new Continuation() { @Override public Void then(Task task) throws Exception { listener.onComplete( task.getResult(), task.getError()); return null; } }, Task.UI_THREAD_EXECUTOR); } } @Override public String getLatestCachedModelDataBuildInfo(String projectName) { if (_cacheProvider != null) { return _cacheProvider.getLatestModelDataBuildInfo(projectName); } else { return null; } } protected Task getHighestVersionAsync( final String projectName, final String sdkVersion, final boolean bulk) { return getHighestVersionFromMap(projectName, sdkVersion) .continueWithTask(new Continuation>() { @Override public Task then(Task task) throws Exception { if (task.isFaulted()) { return getHighestVersionFromServer(projectName, sdkVersion, bulk); } else { return task; } } }) .continueWithTask(new Continuation>() { @Override public Task then(Task task) throws Exception { if (task.isFaulted()) { return getHighestVersionFromCache(projectName, sdkVersion, task); } else { return task; } } }) .onSuccess(new Continuation() { @Override public String then(Task task) throws Exception { String version = task.getResult(); putHighestVersionIntoMap(projectName, sdkVersion, version); return version; } }); } private Task getHighestVersionFromMap( final String projectName, final String sdkVersion) { return Task.callInBackground(new Callable() { @Override public String call() throws Exception { String version = null; Map versionMap = _projectHighestVersionMap.get(projectName); if (versionMap != null) { version = versionMap.get(sdkVersion); } if (version == null) { throw new Exception(); } else { return version; } } }); } private Task getHighestVersionFromServer( final String projectName, final String sdkVersion, final boolean bulk) { return Task .callInBackground(new ThrowNoNetworkCallable()) .onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { return new VersionDownload(projectName, sdkVersion).execute(bulk); } }); } private Task getHighestVersionFromCache( final String projectName, final String sdkVersion, final Task continuation) { String version = _cacheProvider.getLatestVersionForProject(projectName); if (StringUtils.isEmpty(version)) { return continuation; } return Task.forResult(version); } private void putHighestVersionIntoMap( final String projectName, final String sdkVersion, final String version) { if (!StringUtils.isEmpty(version) && !_projectHighestVersionMap.containsKey(projectName)) { HashMap projectVersion = new HashMap<>(1); projectVersion.put(sdkVersion, version); _projectHighestVersionMap.put(projectName, projectVersion); } } /** * Returns the directory containing the project files. If the project files already exist in * the provided {@link com.kofax.mobile.sdk.extract.id.bundle.IBundleCacheProvider IBundleCacheProvider}, * those will be used. *

* If no cache version exists, the network connection is checked. If a network connection * exists, a server call is attempted. If the files are acquired from the server, they are * cached and returned. *

* If no cache exists, and either there's no network connection or a server error, an * exception is thrown with a message indicating work cannot continue. * * @param projectName The name of the project * @param version The version of the project * @param listener A completion listener. If this is null, no work will be done. */ @Override public void getProject( final String projectName, final String version, final ICompletionListener listener) { if (listener != null) { getProjectAsync(projectName, version, false) .continueWith(new Continuation() { @Override public Void then(Task task) throws Exception { listener.onComplete(task.getResult(), task.getError()); return null; } }, Task.UI_THREAD_EXECUTOR); } } private Task getProjectAsync( final String projectName, final String version, final boolean bulk) { return getProjectFromCache(projectName, version) .continueWithTask(new Continuation>() { @Override public Task then(Task task) throws Exception { if (task.isFaulted()) { return getProjectFromServer(projectName, version, bulk); } else { return task; } } }); } private Task getProjectFromCache(final String projectName, final String version) { File cachedProject = _cacheProvider.getProject(projectName, version); if (cachedProject == null) { return Task.forError(new Exception()); } else { return Task.forResult(cachedProject); } } private Task getProjectFromServer( final String projectName, final String version, final boolean bulk) { return Task .callInBackground(new ThrowNoNetworkCallable()) .onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { return new ProjectDownload(projectName, version).execute(bulk); } }); } /** * Returns the directory containing the variant files. If the variant files already exist in * the provided {@link com.kofax.mobile.sdk.extract.id.bundle.IBundleCacheProvider IBundleCacheProvider}, * those will be used. *

* If no cache version exists, the network connection is checked. If a network connection * exists, a server call is attempted. If the files are acquired from the server, they are * cached and returned. *

* If no cache exists, and either there's no network connection or a server error, an * exception is thrown with a message indicating work cannot continue. * * @param projectName The name of the project * @param variantName The name of the variant * @param version The version of the variant (this should match the same version string * retrieved from {@link #getHighestVersion(String, String, ICompletionListener) getHighestVersion(String, String, ICompletionListener)} * @param listener A completion listener. If this is null, no work will be done. */ @SuppressWarnings("JavadocReference") @Override public void getVariant( final String projectName, final String variantName, final String version, final ICompletionListener listener) { if (listener != null) { getVariantAsync(projectName, variantName, version, false) .continueWith(new Continuation() { @Override public Void then(Task task) throws Exception { listener.onComplete(task.getResult(), task.getError()); return null; } }, Task.UI_THREAD_EXECUTOR); } } private Task getVariantAsync( final String projectName, final String variantName, final String version, final boolean bulk) { return getVariantFromCache(projectName, variantName, version) .continueWithTask(new Continuation>() { @Override public Task then(Task task) throws Exception { if (task.isFaulted()) { return getVariantFromServer(projectName, variantName, version, bulk); } else { return task; } } }); } private Task getVariantFromCache( final String projectName, final String variantName, final String version) { File cachedVariant = _cacheProvider.getVariant(projectName, variantName, version); if (cachedVariant == null) { return Task.forError(new Exception()); } else { return Task.forResult(cachedVariant); } } private Task getVariantFromServer( final String projectName, final String variantName, final String version, final boolean bulk) { return Task .callInBackground(new ThrowNoNetworkCallable()) .onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { return new VariantDownload( projectName, variantName, version).execute(bulk); } }); } /** * Loads all the variants for a given project. This method uses the {@link SdkVersion#getSdkVersion()} * when calling {@link #getHighestVersion(String, String, ICompletionListener) getHighestVersion(String, String, ICompletionListener)}. * Subsequently, it retrieves the project files, and uses the contained variants list * to download each variant. *

* If a variant already exists in the provided * {@link com.kofax.mobile.sdk.extract.id.bundle.IBundleCacheProvider IBundleCacheProvider}, * it will not be re-downloaded. * * @param projectName The name of the project to load * @param listener A listener to call when loading is complete. * The type argument is Void because no object is returned. */ @SuppressWarnings({"unused", "JavadocReference"}) public void loadAllVariantsForProject( final String projectName, final ICompletionListener listener) { if (listener != null) { String sdkVersion = sdkVersion(); final Capture projectVersionCapture = new Capture<>(); getHighestVersionAsync(projectName, sdkVersion, true) .onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { projectVersionCapture.set(task.getResult()); return getProjectAsync(projectName, task.getResult(), true); } }) .onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { String projectVersion = projectVersionCapture.get(); String[] variants = getVariants(task.getResult()); List> tasks = new ArrayList<>(); for (String variantName : variants) { tasks.add(getVariantAsync( projectName, variantName, projectVersion, true)); } return Task.whenAll(tasks); } }) .continueWithTask(new ThrowNoNetworkContinuation()) .continueWith(new Continuation() { @Override public Void then(Task task) throws Exception { if (task.isCancelled()) { listener.onComplete( null, new KmcRuntimeException(ErrorInfo.KMC_EV_CANCEL_OPERATION_SUCCESS)); } else { listener.onComplete(null, task.getError()); } return null; } }, Task.UI_THREAD_EXECUTOR); } } /** * Cancels any outstanding tasks related to * {@link #loadAllVariantsForProject(String, ICompletionListener) loadAllVariantsForProject(String, ICompletionListener)} *

* This function will do nothing if no tasks are outstanding. *

* If there are outstanding tasks, the {@link ICompletionListener} passed to * {@link #loadAllVariantsForProject(String, ICompletionListener) loadAllVariantsForProject(String, ICompletionListener)} * will be called with a cancel success message */ @SuppressWarnings("JavadocReference") public void cancelLoadAllVariants() { for (Download download : _bulkDownloads) { download.cancel(); } } private HttpUrl getHighestVersionUrl(String projectName, String sdkVersion) { return getBuilderForSegment(HIGHEST_VERSION_SEGMENT) .addQueryParameter(PROJECT_PARAMETER, projectName) .addQueryParameter(SDK_VERSION_PARAMETER, sdkVersion) .build(); } private HttpUrl getProjectUrl(String projectName, String version) { return getBuilderForSegment(PROJECT_SEGMENT) .addQueryParameter(PROJECT_PARAMETER, projectName) .addQueryParameter(VERSION_PARAMETER, version) .build(); } private HttpUrl getVariantUrl(String projectName, String variantName, String version) { return getBuilderForSegment(VARIANT_SEGMENT) .addQueryParameter(PROJECT_PARAMETER, projectName) .addQueryParameter(VARIANT_PARAMETER, variantName) .addQueryParameter(VERSION_PARAMETER, version) .build(); } private HttpUrl.Builder getBuilderForSegment(String segment) { return HttpUrl .parse(_baseUrl) .newBuilder() .addPathSegment(segment); } private Request getRequest(HttpUrl url) { return new Request.Builder() .url(url) .addHeader("Accept", "application/json") .addHeader("Accept-Encoding", "identity") .build(); } protected String sdkVersion() { return SdkVersion.getSdkVersion(); } private String[] getVariants(File projectFile) throws IOException { File variantsList = new File(projectFile, VARIANTS_LIST); InputStream variantStream = new FileInputStream(variantsList); String json = IOUtils.toString(variantStream); IOUtils.closeQuietly(variantStream); return new Gson().getAdapter(String[].class).fromJson(json); } @KeepProguard static class HttpClientHolder { private static final long DEFAULT_TIMEOUT_DURATION = 10; private static final TimeUnit DEFAULT_TIMEOUT_UNIT = TimeUnit.SECONDS; OkHttpClient client; long timeOutDuration; TimeUnit timeOutUnit; String baseUrl; CertificateValidatorListener validatorListener; private static HttpClientHolder instance; static HttpClientHolder get() { if (instance == null) { instance = new HttpClientHolder(DEFAULT_TIMEOUT_DURATION, DEFAULT_TIMEOUT_UNIT, null, null); } return instance; } private HttpClientHolder(long duration, TimeUnit timeUnit, String baseUrl, CertificateValidatorListener validatorListener) { setNewClient(duration, timeUnit, baseUrl, validatorListener); } private void setNewClient(long timeOutDuration, TimeUnit timeOutUnit, String baseUrl, CertificateValidatorListener validatorListener) { OkHttpClient.Builder okHttpBuilder = new OkHttpClient.Builder(); okHttpBuilder.connectTimeout(timeOutDuration, timeOutUnit); okHttpBuilder.writeTimeout(timeOutDuration, timeOutUnit); okHttpBuilder.readTimeout(timeOutDuration, timeOutUnit); if (validatorListener != null && baseUrl != null) { try { URL url = new URL(baseUrl); if (url.getProtocol().equalsIgnoreCase("https")) { okHttpBuilder.sslSocketFactory(validatorListener.getSSLSocketFactory(url.getHost())); } } catch (Exception e) { throw new ProjectProviderException(e); } } this.client = okHttpBuilder.build(); this.timeOutDuration = timeOutDuration; this.timeOutUnit = timeOutUnit; this.baseUrl = baseUrl; this.validatorListener = validatorListener; } void setTimeOut(long timeOutDuration, TimeUnit timeOutUnit) { setNewClient(timeOutDuration, timeOutUnit, baseUrl, validatorListener); } void setCertificateValidatorListener(String baseUrl, CertificateValidatorListener validatorListener) { setNewClient(timeOutDuration, timeOutUnit, baseUrl, validatorListener); } } @KeepProguard public static class Error { public String message; public String Message; } private class ThrowNoNetworkContinuation implements Continuation> { @Override public Task then(Task task) throws Exception { if (task.isFaulted()) { final Exception error = task.getError(); return Task.callInBackground(new ThrowNoNetworkCallable()) .onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { return Task.delay(20); } }) .onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { return Task.callInBackground(new ThrowNoNetworkCallable()); } }) .onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { return Task.forError(error); } }); } else { return task; } } } private class ThrowNoNetworkCallable implements Callable { @Override public T call() throws Exception { NetworkInfo info = _connectivityManager.getActiveNetworkInfo(); if (info == null || !info.isConnectedOrConnecting()) { throw new ProjectProviderException(_noNetworkException); } return null; } } private abstract class Download implements Callback { private final Request _request; private final Task.TaskCompletionSource _completionSource = Task.create(); private Call _call; public Download(Request request) { _request = request; } public Task execute(boolean addToList) { _call = HttpClientHolder.get().client.newCall(_request); _call.enqueue(this); if (addToList) { _bulkDownloads.add(this); } return _completionSource .getTask() .continueWithTask(new Continuation>() { @Override public Task then(Task task) throws Exception { _bulkDownloads.remove(Download.this); return task; } }) .continueWithTask(new ThrowNoNetworkContinuation()); } public void cancel() { _completionSource.trySetCancelled(); _call.cancel(); } @Override public void onFailure(Call call, IOException e) { onFailure(new ProjectProviderException(e)); } private void onFailure(ProjectProviderException e) { _completionSource.trySetError(e); } @Override public void onResponse(Call call, Response response) throws IOException { ResponseBody body = null; OutputStream outputStream = null; MemoryFile memoryFile = null; try { body = response.body(); if (response.code() == 200) { String uuid = UUID.randomUUID().toString(); memoryFile = new MemoryFile(uuid, (int) body.contentLength()); outputStream = memoryFile.getOutputStream(); IOUtils.copy(body.byteStream(), outputStream); T returnBody = convertBody(memoryFile.getInputStream()); _completionSource.trySetResult(returnBody); } else { Error exception = new Gson() .getAdapter(Error.class) .fromJson(body.string()); String message; if (!StringUtils.isEmpty(exception.message)) { message = exception.message; } else if (!StringUtils.isEmpty(exception.Message)) { message = exception.Message; } else { message = _cannotParseException; } onFailure(new ProjectProviderException(message)); } } catch (Exception e) { onFailure(new ProjectProviderException(e)); } finally { IOUtils.closeQuietly(body); IOUtils.closeQuietly(outputStream); if (memoryFile != null) { memoryFile.close(); } } } protected abstract T convertBody(InputStream body) throws Exception; } private class VersionDownload extends Download { public VersionDownload( String projectName, String sdkVersion) { super(getRequest(getHighestVersionUrl(projectName, sdkVersion))); } @Override protected String convertBody(InputStream body) throws Exception { String versionString = IOUtils.toString(body); return versionString.replace("\"", ""); } } private class ProjectDownload extends Download { private final String _projectName; private final String _version; public ProjectDownload( String projectName, String version) { super(getRequest(getProjectUrl(projectName, version))); _projectName = projectName; _version = version; } @Override protected File convertBody(InputStream body) throws Exception { return _cacheProvider.cacheProject( _projectName, new ZipInputStreamBundle(body), _version); } } private class VariantDownload extends Download { private final String _projectName; private final String _variantName; private final String _version; public VariantDownload( String projectName, String variantName, String version) { super(getRequest(getVariantUrl(projectName, variantName, version))); _projectName = projectName; _variantName = variantName; _version = version; } @Override protected File convertBody(InputStream body) throws Exception { return _cacheProvider.cacheVariant( _projectName, _variantName, new ZipInputStreamBundle(body), _version); } } }