diff --git a/bank-sdk/sdk/src/androidTest/java/net/gini/android/bank/sdk/TransferSummaryIntegrationTest.kt b/bank-sdk/sdk/src/androidTest/java/net/gini/android/bank/sdk/TransferSummaryIntegrationTest.kt index e380222d7f..6f8bb74e89 100644 --- a/bank-sdk/sdk/src/androidTest/java/net/gini/android/bank/sdk/TransferSummaryIntegrationTest.kt +++ b/bank-sdk/sdk/src/androidTest/java/net/gini/android/bank/sdk/TransferSummaryIntegrationTest.kt @@ -267,5 +267,7 @@ class TransferSummaryIntegrationTest { override fun getSource(): Document.Source = Document.Source.newSource("androidTest") override fun isReviewable(): Boolean = false + + override fun generateUploadMetadata(context: Context?): String = "" } } \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/GiniBank.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/GiniBank.kt index 3d24e81657..f3a095ebfb 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/GiniBank.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/GiniBank.kt @@ -30,7 +30,11 @@ import net.gini.android.bank.sdk.error.AmountParsingException import net.gini.android.bank.sdk.pay.getBusinessIntent import net.gini.android.bank.sdk.pay.getRequestId import net.gini.android.bank.sdk.util.parseAmountToBackendFormat -import net.gini.android.capture.* +import net.gini.android.capture.Amount +import net.gini.android.capture.AsyncCallback +import net.gini.android.capture.Document +import net.gini.android.capture.GiniCapture +import net.gini.android.capture.ImportedFileValidationException import net.gini.android.capture.onboarding.view.ImageOnboardingIllustrationAdapter import net.gini.android.capture.onboarding.view.OnboardingIllustrationAdapter import net.gini.android.capture.requirements.GiniCaptureRequirements @@ -61,6 +65,8 @@ object GiniBank { private var captureConfiguration: CaptureConfiguration? = null private var giniApi: GiniBankAPI? = null + internal const val USER_COMMENT_GINI_BANK_VERSION = "GiniBankVer" + /** * Bottom navigation bar adapters. Could be changed to custom ones. */ diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/Configuration.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/Configuration.kt index 1d921d7287..00baabbd84 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/Configuration.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/Configuration.kt @@ -1,5 +1,7 @@ package net.gini.android.bank.sdk.capture +import net.gini.android.bank.sdk.BuildConfig +import net.gini.android.bank.sdk.GiniBank import net.gini.android.bank.sdk.capture.skonto.SkontoNavigationBarBottomAdapter import net.gini.android.capture.DocumentImportEnabledFileTypes import net.gini.android.capture.EntryPoint @@ -233,6 +235,7 @@ internal fun GiniCapture.Builder.applyConfiguration(configuration: CaptureConfig .setBottomNavigationBarEnabled(configuration.bottomNavigationBarEnabled) .setEntryPoint(configuration.entryPoint) .setAllowScreenshots(configuration.allowScreenshots) + .addCustomUploadMetadata(GiniBank.USER_COMMENT_GINI_BANK_VERSION, BuildConfig.VERSION_NAME) .apply { configuration.eventTracker?.let { setEventTracker(it) } configuration.errorLoggerListener?.let { setCustomErrorLoggerListener(it) } diff --git a/capture-sdk/default-network/src/androidTest/java/net/gini/android/capture/network/TransferSummaryIntegrationTest.kt b/capture-sdk/default-network/src/androidTest/java/net/gini/android/capture/network/TransferSummaryIntegrationTest.kt index e4da8da0d9..6b102e068e 100644 --- a/capture-sdk/default-network/src/androidTest/java/net/gini/android/capture/network/TransferSummaryIntegrationTest.kt +++ b/capture-sdk/default-network/src/androidTest/java/net/gini/android/capture/network/TransferSummaryIntegrationTest.kt @@ -262,5 +262,7 @@ class TransferSummaryIntegrationTest { override fun getSource(): Document.Source = Document.Source.newSource("androidTest") override fun isReviewable(): Boolean = false + + override fun generateUploadMetadata(context: Context?): String = "" } } \ No newline at end of file diff --git a/capture-sdk/default-network/src/main/java/net/gini/android/capture/network/GiniCaptureDefaultNetworkService.kt b/capture-sdk/default-network/src/main/java/net/gini/android/capture/network/GiniCaptureDefaultNetworkService.kt index b0493c9e6b..e33ad54592 100644 --- a/capture-sdk/default-network/src/main/java/net/gini/android/capture/network/GiniCaptureDefaultNetworkService.kt +++ b/capture-sdk/default-network/src/main/java/net/gini/android/capture/network/GiniCaptureDefaultNetworkService.kt @@ -2,6 +2,7 @@ package net.gini.android.capture.network import android.content.Context import android.text.TextUtils +import androidx.annotation.VisibleForTesting import androidx.annotation.XmlRes import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -57,10 +58,14 @@ import net.gini.android.bank.api.models.Configuration as BankConfiguration * [GiniCapture.Builder.setGiniCaptureNetworkService] when creating a * [GiniCapture] instance. */ -class GiniCaptureDefaultNetworkService( +class GiniCaptureDefaultNetworkService + +@VisibleForTesting +internal constructor( internal val giniBankApi: GiniBankAPI, private val documentMetadata: DocumentMetadata?, - coroutineContext: CoroutineContext = Dispatchers.Main + private val context: Context, + coroutineContext: CoroutineContext = Dispatchers.Main, ) : GiniCaptureNetworkService { private val coroutineScope = CoroutineScope(coroutineContext) @@ -212,12 +217,18 @@ class GiniCaptureDefaultNetworkService( return@launchCancellable } + val uploadMetadata = document.generateUploadMetadata(context) + val partialDocumentResource = giniBankApi.documentManager.createPartialDocument( document = documentData, contentType = document.mimeType, filename = null, documentType = null, - documentMetadata + documentMetadata?.copy()?.apply { + setUploadMetadata(uploadMetadata) + } ?: DocumentMetadata().apply { + setUploadMetadata(uploadMetadata) + } ) when (partialDocumentResource) { @@ -512,7 +523,7 @@ class GiniCaptureDefaultNetworkService( trustManager?.let { giniApiBuilder.setTrustManager(it) } giniApiBuilder.setDebuggingEnabled(isDebuggingEnabled) val giniBankApi = giniApiBuilder.build() - return GiniCaptureDefaultNetworkService(giniBankApi, documentMetadata) + return GiniCaptureDefaultNetworkService(giniBankApi, documentMetadata, mContext) } /** diff --git a/capture-sdk/default-network/src/test/java/net/gini/android/capture/network/GiniCaptureDefaultNetworkServiceTest.kt b/capture-sdk/default-network/src/test/java/net/gini/android/capture/network/GiniCaptureDefaultNetworkServiceTest.kt index f9766942c8..16aa856d37 100644 --- a/capture-sdk/default-network/src/test/java/net/gini/android/capture/network/GiniCaptureDefaultNetworkServiceTest.kt +++ b/capture-sdk/default-network/src/test/java/net/gini/android/capture/network/GiniCaptureDefaultNetworkServiceTest.kt @@ -2,6 +2,7 @@ package net.gini.android.capture.network import android.net.Uri import android.os.Looper +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import io.mockk.* @@ -13,7 +14,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf import java.util.* -import kotlin.collections.LinkedHashMap /** * Created by Alpár Szotyori on 25.02.22. @@ -56,7 +56,7 @@ class GiniCaptureDefaultNetworkServiceTest { // Mock DocumentTaskManager returning the mock documents val documentManager = mockk() - coEvery { documentManager.createPartialDocument(any(), any(), null, null) } returns Resource.Success(partialDocument) + coEvery { documentManager.createPartialDocument(any(), any(), null, null, any()) } returns Resource.Success(partialDocument) coEvery { documentManager.createCompositeDocument(any>(), any()) } returns Resource.Success(compositeDocument) coEvery { documentManager.pollDocument(any()) } returns Resource.Success(compositeDocument) coEvery { documentManager.getAllExtractionsWithPolling(any()) } returns Resource.Success(mockk()) @@ -65,13 +65,14 @@ class GiniCaptureDefaultNetworkServiceTest { val bankApi = mockk() every { bankApi.documentManager } returns documentManager - val networkService = GiniCaptureDefaultNetworkService(bankApi, null) + val networkService = GiniCaptureDefaultNetworkService(bankApi, null, ApplicationProvider.getApplicationContext()) // Mock Gini Capture SDK document val captureDocument = mockk() every { captureDocument.id } returns "id" every { captureDocument.data } returns byteArrayOf() every { captureDocument.mimeType } returns "image/jpeg" + every { captureDocument.generateUploadMetadata(ApplicationProvider.getApplicationContext()) } returns "" // When networkService.upload(captureDocument, mockk(relaxed = true)) diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/Document.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/Document.java index 1aed8c6c6c..d09b594815 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/Document.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/Document.java @@ -1,16 +1,17 @@ package net.gini.android.capture; +import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; -import java.util.HashMap; -import java.util.Map; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.HashMap; +import java.util.Map; + /** * This class is the container for transferring documents between the client application and the @@ -120,6 +121,12 @@ public interface Document extends Parcelable { */ boolean isReviewable(); + /** + * Generate metadata to be sent to backend when creating partial document. + * + */ + String generateUploadMetadata(Context context); + /** * Supported document types. */ diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/GiniCapture.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/GiniCapture.java index 3579902d81..5a75a9f2da 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/GiniCapture.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/GiniCapture.java @@ -125,6 +125,8 @@ public class GiniCapture { private final EntryPoint entryPoint; private final boolean allowScreenshots; + private final Map mCustomUploadMetadata; + /** * Retrieve the current instance. @@ -345,6 +347,7 @@ private GiniCapture(@NonNull final Builder builder) { onButtonLoadingIndicatorAdapterInstance = builder.getOnButtonLoadingIndicatorAdapterInstance(); entryPoint = builder.getEntryPoint(); allowScreenshots = builder.getAllowScreenshots(); + mCustomUploadMetadata = builder.getCustomUploadMetadata(); } /** @@ -695,6 +698,14 @@ public boolean getAllowScreenshots() { return allowScreenshots; } + /** + * Get upload metadata to be added to the HTTP headers + * + * @return the map of custom metadata + */ + @Nullable + public Map getCustomUploadMetadata() { return mCustomUploadMetadata; } + public static GiniCaptureFragment createGiniCaptureFragment() { if (!GiniCapture.hasInstance()) { throw new IllegalStateException("GiniCapture instance was created. Call GiniCapture.newInstance() before creating the GiniCaptureFragment."); @@ -808,6 +819,8 @@ public void onAnalysisScreenEvent(@NotNull final Event even private EntryPoint entryPoint = Internal.DEFAULT_ENTRY_POINT; private boolean allowScreenshots = true; + private Map customUploadMetadata; + /** * Create a new {@link GiniCapture} instance. */ @@ -1344,6 +1357,18 @@ public Builder setAllowScreenshots(boolean allowScreenshots) { private boolean getAllowScreenshots() { return allowScreenshots; } + + public Builder addCustomUploadMetadata(String key, String value) { + if (customUploadMetadata == null) { + customUploadMetadata = new HashMap<>(); + } + customUploadMetadata.put(key, value); + return this; + } + + private Map getCustomUploadMetadata() { + return customUploadMetadata; + } } /** diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/document/GiniCaptureDocument.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/document/GiniCaptureDocument.java index 63591f4a11..4e9b85c574 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/document/GiniCaptureDocument.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/document/GiniCaptureDocument.java @@ -5,18 +5,19 @@ import android.net.Uri; import android.os.Parcel; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import net.gini.android.capture.AsyncCallback; import net.gini.android.capture.Document; import net.gini.android.capture.internal.camera.photo.ParcelableMemoryCache; +import net.gini.android.capture.internal.util.DeviceHelper; import net.gini.android.capture.internal.util.UriReaderAsyncTask; import net.gini.android.capture.util.IntentHelper; import java.util.Arrays; import java.util.UUID; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - /** * Internal use only. * @@ -219,6 +220,16 @@ public String getParcelableMemoryCacheTag() { return mParcelableMemoryCacheTag; } + @Override + public String generateUploadMetadata(Context context) { + return UploadMetadata.INSTANCE + .setSource(mSource.getName()) + .setDeviceType(DeviceHelper.getDeviceType(context)) + .setDeviceOrientation(DeviceHelper.getDeviceOrientation(context)) + .setImportMethod(mImportMethod.name()) + .build(); + } + @Override public String toString() { return "GiniCaptureDocument{" @@ -309,6 +320,7 @@ public boolean equals(final Object o) { if (mImportMethod != that.mImportMethod) { return false; } + return mMimeType.equals(that.mMimeType); } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/document/UploadMetadata.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/document/UploadMetadata.kt new file mode 100644 index 0000000000..e20e3965b6 --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/document/UploadMetadata.kt @@ -0,0 +1,88 @@ +package net.gini.android.capture.document + +import android.os.Build +import net.gini.android.capture.BuildConfig +import net.gini.android.capture.EntryPoint +import net.gini.android.capture.GiniCapture + +internal object UploadMetadata { + + private const val USER_COMMENT_PLATFORM = "Platform" + private const val USER_COMMENT_OS_VERSION = "OSVer" + private const val USER_COMMENT_GINI_CAPTURE_VERSION = "GiniCaptureVer" + private const val USER_COMMENT_DEVICE_ORIENTATION = "DeviceOrientation" + private const val USER_COMMENT_DEVICE_TYPE = "DeviceType" + private const val USER_COMMENT_SOURCE = "Source" + private const val USER_COMMENT_IMPORT_METHOD = "ImportMethod" + private const val USER_COMMENT_ENTRY_POINT = "EntryPoint" + + private var giniCaptureVersion: String = "" + private var deviceOrientation: String = "" + private var deviceType: String = "" + private var source: String = "" + private var importMethod: String = "" + + private fun convertMapToCSV(keyValueMap: Map): String { + val csvBuilder = StringBuilder() + var isFirst = true + for ((key, value) in keyValueMap) { + if (!isFirst) { + csvBuilder.append(',') + } + isFirst = false + csvBuilder.append(key) + .append('=') + .append(value) + } + return csvBuilder.toString() + } + + fun setDeviceOrientation(deviceOrientation: String): UploadMetadata = + this.also { it.deviceOrientation = deviceOrientation } + + fun setDeviceType(deviceType: String): UploadMetadata = + this.also { it.deviceType = deviceType } + + fun setSource(source: String): UploadMetadata = this.also { it.source = source } + + fun setImportMethod(importMethod: String): UploadMetadata = this.also { it.importMethod = importMethod } + + fun build(): String { + val metadataMap = mutableMapOf() + + metadataMap[USER_COMMENT_PLATFORM] = "Android" + metadataMap[USER_COMMENT_OS_VERSION] = Build.VERSION.RELEASE.toString() + if (giniCaptureVersion.isNotEmpty()) { + metadataMap[USER_COMMENT_GINI_CAPTURE_VERSION] = giniCaptureVersion + } + if (deviceOrientation.isNotEmpty()) { + metadataMap[USER_COMMENT_DEVICE_ORIENTATION] = deviceOrientation + } + if (deviceType.isNotEmpty()) { + metadataMap[USER_COMMENT_DEVICE_TYPE] = deviceType + } + if (source.isNotEmpty()) { + metadataMap[USER_COMMENT_SOURCE] = source + } + if (importMethod.isNotEmpty()) { + metadataMap[USER_COMMENT_IMPORT_METHOD] = importMethod + } + metadataMap[USER_COMMENT_GINI_CAPTURE_VERSION] = BuildConfig.VERSION_NAME.replace(" ", "") + + if (GiniCapture.hasInstance()) { + metadataMap[USER_COMMENT_ENTRY_POINT] = entryPointToString(GiniCapture.getInstance().entryPoint) + GiniCapture.getInstance().customUploadMetadata?.forEach { + metadataMap[it.key] = it.value + } + } else { + metadataMap[USER_COMMENT_ENTRY_POINT] = entryPointToString(GiniCapture.Internal.DEFAULT_ENTRY_POINT) + } + + return convertMapToCSV(metadataMap) + } + + private fun entryPointToString(entryPoint: EntryPoint) = when (entryPoint) { + EntryPoint.FIELD -> "field" + EntryPoint.BUTTON -> "button" + } +} diff --git a/core-api-library/library/src/main/java/net/gini/android/core/api/DocumentMetadata.java b/core-api-library/library/src/main/java/net/gini/android/core/api/DocumentMetadata.java index b14f6ef10d..5343a92276 100644 --- a/core-api-library/library/src/main/java/net/gini/android/core/api/DocumentMetadata.java +++ b/core-api-library/library/src/main/java/net/gini/android/core/api/DocumentMetadata.java @@ -1,14 +1,14 @@ package net.gini.android.core.api; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + import java.nio.charset.Charset; import java.nio.charset.CharsetEncoder; import java.util.HashMap; import java.util.Map; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - /** * Created by Alpar Szotyori on 25.10.2018. * @@ -26,6 +26,8 @@ public class DocumentMetadata { public static final String HEADER_FIELD_NAME_PREFIX = "X-Document-Metadata-"; @VisibleForTesting public static final String BRANCH_ID_HEADER_FIELD_NAME = HEADER_FIELD_NAME_PREFIX + "BranchId"; + @VisibleForTesting + public static final String UPLOAD_METADATA_HEADER_FIELD_NAME = HEADER_FIELD_NAME_PREFIX + "Upload"; private final Map mMetadataMap = new HashMap<>(); @@ -63,6 +65,19 @@ public void setBranchId(@NonNull final String branchId) throws IllegalArgumentEx } } + /** + * Set upload metadata to be sent to backend + * + * @param uploadMetadata containing info related to the device, file import type etc... + */ + public void setUploadMetadata(@NonNull final String uploadMetadata) { + if (isASCIIEncodable(uploadMetadata)) { + mMetadataMap.put(UPLOAD_METADATA_HEADER_FIELD_NAME, uploadMetadata); + } else { + throw new IllegalArgumentException("Metadata is not encodable as ASCII: " + uploadMetadata); + } + } + @VisibleForTesting public boolean isASCIIEncodable(@NonNull final String string) { if (mAsciiCharsetEncoder != null) { @@ -98,6 +113,17 @@ public void add(@NonNull final String name, @NonNull final String value) mMetadataMap.put(completeName, value); } + /** + * Provides a copy of the [DocumentMetadata] object + * + * @return the copy of the metadata object + */ + public DocumentMetadata copy() { + DocumentMetadata copy = new DocumentMetadata(); + mMetadataMap.forEach((key, value) -> copy.add(key, value)); + return copy; + } + @NonNull @VisibleForTesting public Map getMetadata() {