diff --git a/.github/workflows/bank-api-library.check.yml b/.github/workflows/bank-api-library.check.yml index 8f6d0d8e12..8a2c2d7b79 100644 --- a/.github/workflows/bank-api-library.check.yml +++ b/.github/workflows/bank-api-library.check.yml @@ -6,7 +6,7 @@ permissions: on: workflow_dispatch: pull_request: - types: [opened, edited, reopened, synchronize] + types: [ opened, edited, reopened, synchronize ] paths: - 'bank-api-library/**' - 'core-api-library/**' @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - api-level: [22, 31] + api-level: [ 22, 31 ] steps: - name: checkout uses: actions/checkout@v4 @@ -177,19 +177,35 @@ jobs: steps: - name: checkout uses: actions/checkout@v4 - - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - - name: run ktlint run: ./gradlew bank-api-library:library:ktlintCheck - - name: archive ktlint report uses: actions/upload-artifact@v4 with: name: bank-api-library-ktlint-report path: bank-api-library/library/build/reports/ktlint/**/*.html - \ No newline at end of file + + sonar: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Build Bank API Library + run: ./gradlew :bank-api-library:library:assembleDebug + + - name: SonarQube Scan + run: ./gradlew :bank-api-library:library:sonar --info --stacktrace + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/bank-sdk.check.yml b/.github/workflows/bank-sdk.check.yml index 54e9c6b16d..0c4b07247a 100644 --- a/.github/workflows/bank-sdk.check.yml +++ b/.github/workflows/bank-sdk.check.yml @@ -1,7 +1,8 @@ name: Check Bank SDK permissions: - contents: read + contents: write + packages: write on: workflow_dispatch: @@ -19,6 +20,8 @@ on: required: true BANK_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD: required: true + SONAR_TOKEN: + required: true concurrency: group: ${{ github.workflow }}-${{ github.ref }}-check @@ -251,3 +254,27 @@ jobs: with: name: bank-sdk-ktlint-report path: bank-sdk/sdk/build/reports/ktlint/**/*.html + sonar: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + cache: "gradle" + + # Build both modules (example-app depends on sdk) + - name: Build modules for Sonar + run: | + ./gradlew bank-sdk:sdk:assembleDebug \ + bank-sdk:example-app:assembleDevExampleAppDebug + + # Run Sonar on both modules + - name: SonarQube Scan + run: ./gradlew bank-sdk:sdk:sonar bank-sdk:example-app:sonar --info --stacktrace + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/bank-sdk.publish.firebase.example.yml b/.github/workflows/bank-sdk.publish.firebase.example.yml index e42785ab1f..4a17ea9a80 100644 --- a/.github/workflows/bank-sdk.publish.firebase.example.yml +++ b/.github/workflows/bank-sdk.publish.firebase.example.yml @@ -24,7 +24,8 @@ jobs: secrets: GINI_MOBILE_TEST_CLIENT_SECRET: ${{ secrets.GINI_MOBILE_TEST_CLIENT_SECRET }} BANK_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD: ${{ secrets.BANK_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD }} - + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + publish_to_firebase_distribution: needs: check runs-on: ubuntu-latest diff --git a/.github/workflows/capture-sdk.check.yml b/.github/workflows/capture-sdk.check.yml index d45e6d8796..55ef2ab008 100644 --- a/.github/workflows/capture-sdk.check.yml +++ b/.github/workflows/capture-sdk.check.yml @@ -1,7 +1,9 @@ name: Check Capture SDK permissions: - contents: read + contents: write + packages: write + on: workflow_dispatch: @@ -322,3 +324,27 @@ jobs: with: name: capture-sdk-ktlint-report path: capture-sdk/sdk/build/reports/ktlint/**/*.html + sonar: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + cache: "gradle" + + # Build both modules (example-app depends on sdk) + - name: Build modules for Sonar + run: | + ./gradlew capture-sdk:sdk:assembleDebug \ + capture-sdk:default-network:assembleDebug + + # Run Sonar on both modules + - name: SonarQube Scan + run: ./gradlew capture-sdk:sdk:sonar capture-sdk:default-network:sonar --info --stacktrace + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/capture-sdk.release.yml b/.github/workflows/capture-sdk.release.yml index a7e9e066a6..6c23913681 100644 --- a/.github/workflows/capture-sdk.release.yml +++ b/.github/workflows/capture-sdk.release.yml @@ -17,7 +17,8 @@ jobs: secrets: GINI_MOBILE_TEST_CLIENT_SECRET: ${{ secrets.GINI_MOBILE_TEST_CLIENT_SECRET }} CAPTURE_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD: ${{ secrets.CAPTURE_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD }} - + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + release: needs: check runs-on: ubuntu-latest diff --git a/.github/workflows/core-api-library.check.yml b/.github/workflows/core-api-library.check.yml index 7d68de9375..76b91b9fd4 100644 --- a/.github/workflows/core-api-library.check.yml +++ b/.github/workflows/core-api-library.check.yml @@ -1,7 +1,8 @@ name: Check Core API Library permissions: - contents: read + contents: write + packages: write on: workflow_dispatch: @@ -183,4 +184,27 @@ jobs: with: name: core-api-library-ktlint-report path: core-api-library/library/build/reports/ktlint/**/*.html - \ No newline at end of file + sonar: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + cache: "gradle" + + # Build both modules (example-app depends on sdk) + - name: Build modules for Sonar + run: | + ./gradlew core-api-library:library:assembleDebug \ + core-api-library:shared-tests:assembleDebug + + # Run Sonar on both modules + - name: SonarQube Scan + run: ./gradlew core-api-library:library:sonar core-api-library:shared-tests:sonar --info --stacktrace + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/gpc.publish.firebase.example.yml b/.github/workflows/gpc.publish.firebase.example.yml index 3852c154fa..e3fe9862da 100644 --- a/.github/workflows/gpc.publish.firebase.example.yml +++ b/.github/workflows/gpc.publish.firebase.example.yml @@ -11,7 +11,8 @@ jobs: secrets: GINI_MOBILE_TEST_CLIENT_SECRET: ${{ secrets.GINI_MOBILE_TEST_CLIENT_SECRET }} BANK_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD: ${{ secrets.BANK_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD }} - + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + publish_to_firebase_distribution: needs: check runs-on: ubuntu-latest diff --git a/.github/workflows/health-api-library.check.yml b/.github/workflows/health-api-library.check.yml index 8e69f2b736..5d8bf18030 100644 --- a/.github/workflows/health-api-library.check.yml +++ b/.github/workflows/health-api-library.check.yml @@ -238,4 +238,23 @@ jobs: with: name: health-api-library-ktlint-report path: health-api-library/library/build/reports/ktlint/**/*.html - \ No newline at end of file + + sonar: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Build health API Library + run: ./gradlew :health-api-library:library:assembleDebug + + - name: SonarQube Scan + run: ./gradlew :health-api-library:library:sonar --info --stacktrace + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/health-sdk.check.yml b/.github/workflows/health-sdk.check.yml index 7b84c6cb3c..110c57142e 100644 --- a/.github/workflows/health-sdk.check.yml +++ b/.github/workflows/health-sdk.check.yml @@ -1,7 +1,8 @@ name: Check Health SDK permissions: - contents: read + contents: write + packages: write on: workflow_dispatch: @@ -18,6 +19,8 @@ on: required: true HEALTH_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD: required: true + SONAR_TOKEN: + required: true concurrency: group: ${{ github.workflow }}-${{ github.ref }}-check @@ -153,3 +156,31 @@ jobs: with: name: health-sdk-ktlint-report path: health-sdk/sdk/build/reports/ktlint/**/*.html + + sonar: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + cache: "gradle" + + # Build both modules (example-app depends on sdk) + - name: Build modules for Sonar + run: | + ./gradlew \ + health-sdk:sdk:assembleDebug \ + health-sdk:example-app:assembleDebug \ + -PclientId="gini-mobile-ci" \ + -PclientSecret="${{ secrets.GINI_MOBILE_TEST_CLIENT_SECRET }}" + + # Run Sonar on both modules + - name: SonarQube Scan + run: ./gradlew health-sdk:sdk:sonar health-sdk:example-app:sonar --info --stacktrace + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/health-sdk.publish.firebase.example.yml b/.github/workflows/health-sdk.publish.firebase.example.yml index 1892b56062..98533e16da 100644 --- a/.github/workflows/health-sdk.publish.firebase.example.yml +++ b/.github/workflows/health-sdk.publish.firebase.example.yml @@ -23,7 +23,8 @@ jobs: secrets: GINI_MOBILE_TEST_CLIENT_SECRET: ${{ secrets.GINI_MOBILE_TEST_CLIENT_SECRET }} HEALTH_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD: ${{ secrets.HEALTH_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD }} - + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + publish_to_firebase_distribution: needs: check runs-on: ubuntu-latest diff --git a/.github/workflows/internal-payment-sdk.check.yml b/.github/workflows/internal-payment-sdk.check.yml index 41a77d2f44..0239015b37 100644 --- a/.github/workflows/internal-payment-sdk.check.yml +++ b/.github/workflows/internal-payment-sdk.check.yml @@ -110,3 +110,23 @@ jobs: with: name: internal-payment-sdk-ktlint-report path: internal-payment-sdk/sdk/build/reports/ktlint/**/*.html + + sonar: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Build Internal Payment SDK + run: ./gradlew :internal-payment-sdk:sdk:assembleDebug + + - name: SonarQube Scan + run: ./gradlew :internal-payment-sdk:sdk:sonar --info --stacktrace + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/merchant-sdk.check.yml b/.github/workflows/merchant-sdk.check.yml index e954214cc7..3813ef78e2 100644 --- a/.github/workflows/merchant-sdk.check.yml +++ b/.github/workflows/merchant-sdk.check.yml @@ -1,7 +1,8 @@ name: Check Merchant SDK permissions: - contents: read + contents: write + packages: write on: workflow_dispatch: @@ -19,6 +20,8 @@ on: required: true MERCHANT_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD: required: true + SONAR_TOKEN: + required: true concurrency: group: ${{ github.workflow }}-${{ github.ref }}-check @@ -154,3 +157,31 @@ jobs: with: name: merchant-sdk-ktlint-report path: merchant-sdk/sdk/build/reports/ktlint/**/*.html + + sonar: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + cache: "gradle" + + # Build both modules (example-app depends on sdk) + - name: Build modules for Sonar + run: | + ./gradlew \ + merchant-sdk:sdk:assembleDebug \ + merchant-sdk:example-app:assembleDebug \ + -PclientId="gini-mobile-ci" \ + -PclientSecret="${{ secrets.GINI_MOBILE_TEST_CLIENT_SECRET }}" + + # Run Sonar on both modules + - name: SonarQube Scan + run: ./gradlew merchant-sdk:sdk:sonar merchant-sdk:example-app:sonar --info --stacktrace + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/merchant-sdk.publish.firebase.example.yml b/.github/workflows/merchant-sdk.publish.firebase.example.yml index c32060fafe..6b1d19e855 100644 --- a/.github/workflows/merchant-sdk.publish.firebase.example.yml +++ b/.github/workflows/merchant-sdk.publish.firebase.example.yml @@ -23,6 +23,7 @@ jobs: secrets: GINI_MOBILE_TEST_CLIENT_SECRET: ${{ secrets.GINI_MOBILE_TEST_CLIENT_SECRET }} MERCHANT_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD: ${{ secrets.MERCHANT_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} publish_to_firebase_distribution: needs: check diff --git a/bank-api-library/library/build.gradle.kts b/bank-api-library/library/build.gradle.kts index f77d6f24e7..fb2eb526ca 100644 --- a/bank-api-library/library/build.gradle.kts +++ b/bank-api-library/library/build.gradle.kts @@ -5,9 +5,18 @@ import org.jetbrains.dokka.gradle.DokkaCollectorTask plugins { id("com.android.library") kotlin("android") + id ("org.sonarqube") alias(libs.plugins.devtools.ksp) } +sonar { + properties { + property("sonar.projectKey", "android-bank-api-library") + property("sonar.organization", "gini") + property("sonar.sources", "src/main/java") + property("sonar.host.url", "https://sonarcloud.io") + } +} android { // after upgrading to AGP 8, we need this (copied from the module's AndroidManifest.xml namespace = "net.gini.android.bank.api" @@ -114,8 +123,18 @@ tasks.register("injectTestProperties") { doFirst { propertiesMap.clear() - propertiesMap.putAll(readLocalPropertiesToMapSilent(project, - listOf("testClientId", "testClientSecret", "testApiUri", "testUserCenterUri", "testHealthApiUri"))) + propertiesMap.putAll( + readLocalPropertiesToMapSilent( + project, + listOf( + "testClientId", + "testClientSecret", + "testApiUri", + "testUserCenterUri", + "testHealthApiUri" + ) + ) + ) } destinations.put( diff --git a/bank-api-library/library/src/main/java/net/gini/android/bank/api/models/Configuration.kt b/bank-api-library/library/src/main/java/net/gini/android/bank/api/models/Configuration.kt index 3348c1fbbc..84fad64323 100644 --- a/bank-api-library/library/src/main/java/net/gini/android/bank/api/models/Configuration.kt +++ b/bank-api-library/library/src/main/java/net/gini/android/bank/api/models/Configuration.kt @@ -6,9 +6,11 @@ data class Configuration( val isSkontoEnabled: Boolean, val isReturnAssistantEnabled: Boolean, val amplitudeApiKey: String?, - val transactionDocsEnabled: Boolean, - val instantPaymentEnabled: Boolean, + val isTransactionDocsEnabled: Boolean, + val isInstantPaymentEnabled: Boolean, val isEInvoiceEnabled: Boolean, - val qrCodeEducationEnabled: Boolean, - val paymentHintsEnabled: Boolean + val isQrCodeEducationEnabled: Boolean, + val isSavePhotosLocallyEnabled: Boolean, + val isAlreadyPaidHintEnabled: Boolean, + val isPaymentDueHintEnabled: Boolean, ) diff --git a/bank-api-library/library/src/main/java/net/gini/android/bank/api/response/ConfigurationResponse.kt b/bank-api-library/library/src/main/java/net/gini/android/bank/api/response/ConfigurationResponse.kt index 0bc3b43e47..d5fbf2656c 100644 --- a/bank-api-library/library/src/main/java/net/gini/android/bank/api/response/ConfigurationResponse.kt +++ b/bank-api-library/library/src/main/java/net/gini/android/bank/api/response/ConfigurationResponse.kt @@ -15,7 +15,9 @@ data class ConfigurationResponse( @Json(name = "qrCodeEducationEnabled") val qrCodeEducationEnabled: Boolean?, @Json(name = "instantPaymentEnabled") val instantPaymentEnabled: Boolean?, @Json(name = "eInvoiceEnabled") val eInvoiceEnabled: Boolean?, - @Json(name = "paymentHintsEnabled") val paymentHintsEnabled: Boolean?, + @Json(name = "alreadyPaidHintEnabled") val alreadyPaidHintEnabled: Boolean?, + @Json(name = "paymentDueHintEnabled") val paymentDueHintEnabled: Boolean?, + @Json(name = "savePhotosLocallyEnabled") val savePhotosLocallyEnabled: Boolean?, ) internal fun ConfigurationResponse.toConfiguration() = Configuration( @@ -24,10 +26,12 @@ internal fun ConfigurationResponse.toConfiguration() = Configuration( isSkontoEnabled = skontoEnabled ?: false, isReturnAssistantEnabled = returnAssistantEnabled ?: false, amplitudeApiKey = amplitudeApiKey, - transactionDocsEnabled = transactionDocsEnabled ?: false, - qrCodeEducationEnabled = qrCodeEducationEnabled ?: false, - instantPaymentEnabled = instantPaymentEnabled ?: false, + isTransactionDocsEnabled = transactionDocsEnabled ?: false, + isQrCodeEducationEnabled = qrCodeEducationEnabled ?: false, + isInstantPaymentEnabled = instantPaymentEnabled ?: false, isEInvoiceEnabled = eInvoiceEnabled ?: false, - paymentHintsEnabled = paymentHintsEnabled ?: false + isAlreadyPaidHintEnabled = alreadyPaidHintEnabled ?: false, + isPaymentDueHintEnabled = paymentDueHintEnabled ?: false, + isSavePhotosLocallyEnabled = savePhotosLocallyEnabled ?: false, ) diff --git a/bank-api-library/library/src/test/java/net/gini/android/bank/api/BankApiDocumentRemoteSourceTest.kt b/bank-api-library/library/src/test/java/net/gini/android/bank/api/BankApiDocumentRemoteSourceTest.kt index 7f27c12f6c..f53f1ca6e0 100644 --- a/bank-api-library/library/src/test/java/net/gini/android/bank/api/BankApiDocumentRemoteSourceTest.kt +++ b/bank-api-library/library/src/test/java/net/gini/android/bank/api/BankApiDocumentRemoteSourceTest.kt @@ -114,17 +114,22 @@ class BankApiDocumentRemoteSourceTest { } override suspend fun getConfigurations(bearer: Map): Response { - return Response.success(ConfigurationResponse( - null, - null, - null, - null, - null, - null, - null, - null, - null - )) + return Response.success( + ConfigurationResponse( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ) + ) } override suspend fun uploadDocument( diff --git a/bank-sdk/example-app/build.gradle.kts b/bank-sdk/example-app/build.gradle.kts index 6e55f02c4b..1accf62ef4 100644 --- a/bank-sdk/example-app/build.gradle.kts +++ b/bank-sdk/example-app/build.gradle.kts @@ -8,12 +8,22 @@ plugins { kotlin("android") id("com.google.dagger.hilt.android") id("kotlin-parcelize") + id ("org.sonarqube") id("androidx.navigation.safeargs.kotlin") id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" alias(libs.plugins.compose.compiler) alias(libs.plugins.devtools.ksp) } +sonar { + properties { + property("sonar.projectKey", "android-bank-sdk") + property("sonar.organization", "gini") + property("sonar.sources", "src/main/java") + property("sonar.host.url", "https://sonarcloud.io") + } +} + // TODO: construct version code and name in fastlane and inject them //apply from: rootProject.file("gradle/git_utils.gradle") //apply from: rootProject.file("gradle/gini_credentials.gradle") diff --git a/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/ConfigurationActivity.kt b/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/ConfigurationActivity.kt index f9181c260b..d587165791 100644 --- a/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/ConfigurationActivity.kt +++ b/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/ConfigurationActivity.kt @@ -18,9 +18,11 @@ import net.gini.android.bank.sdk.exampleapp.core.DefaultNetworkServicesProvider import net.gini.android.bank.sdk.exampleapp.databinding.ActivityConfigurationBinding import net.gini.android.bank.sdk.exampleapp.ui.MainActivity.Companion.CAMERA_PERMISSION_BUNDLE import net.gini.android.bank.sdk.exampleapp.ui.MainActivity.Companion.CONFIGURATION_BUNDLE -import net.gini.android.bank.sdk.exampleapp.ui.data.Configuration +import net.gini.android.bank.sdk.exampleapp.ui.data.ExampleAppBankConfiguration import net.gini.android.capture.DocumentImportEnabledFileTypes import net.gini.android.capture.internal.util.ActivityHelper.interceptOnBackPressed +import net.gini.android.capture.util.SharedPreferenceHelper +import net.gini.android.capture.util.SharedPreferenceHelper.SAF_STORAGE_URI_KEY import javax.inject.Inject @AndroidEntryPoint @@ -107,7 +109,7 @@ class ConfigurationActivity : AppCompatActivity() { finish() } - private fun updateUIWithConfigurationObject(configuration: Configuration) { + private fun updateUIWithConfigurationObject(configuration: ExampleAppBankConfiguration) { // setup sdk with default configuration binding.layoutFeatureToggle.switchSetupSdkWithDefaultConfiguration.isChecked = configuration.isDefaultSDKConfigurationsEnabled @@ -115,6 +117,8 @@ class ConfigurationActivity : AppCompatActivity() { binding.layoutFeatureToggle.switchOpenWith.isChecked = configuration.isFileImportEnabled // Capture SDK binding.layoutFeatureToggle.switchCaptureSdk.isChecked = configuration.isCaptureSDK + // Saving Invoices Locally + binding.layoutFeatureToggle.switchSaveInvoicesLocallyFeature.isChecked = configuration.saveInvoicesLocallyEnabled // QR code scanning binding.layoutFeatureToggle.switchQrCodeScanning.isChecked = configuration.isQrCodeEnabled // only QR code scanning @@ -225,8 +229,16 @@ class ConfigurationActivity : AppCompatActivity() { configuration.isReturnAssistantEnabled // enable payment hints - binding.layoutFeatureToggle.switchSetupPaymentHints.isChecked = - configuration.isPaymentHintsEnabled + binding.layoutFeatureToggle.switchSetupAlreadyPaidHintEnabled.isChecked = + configuration.isAlreadyPaidHintEnabled + + // enable payment due hint + binding.layoutFeatureToggle.switchPaymentDueHint.isChecked = + configuration.isPaymentDueHintEnabled + + // set payment due hint threshold days + binding.layoutFeatureToggle.editTextPaymentDueHintThresholdDays.hint = + configuration.paymentDueHintThresholdDays.toString() // enable return reasons dialog binding.layoutReturnAssistantToggles.switchReturnReasonsDialog.isChecked = @@ -561,14 +573,36 @@ class ConfigurationActivity : AppCompatActivity() { } //enable payment hints for showing warning - binding.layoutFeatureToggle.switchSetupPaymentHints.setOnCheckedChangeListener{ _, isChecked -> + binding.layoutFeatureToggle.switchSetupAlreadyPaidHintEnabled.setOnCheckedChangeListener{ _, isChecked -> configurationViewModel.setConfiguration( configurationViewModel.configurationFlow.value.copy( - isPaymentHintsEnabled = isChecked + isAlreadyPaidHintEnabled = isChecked ) ) } + //enable payment due hint for showing warning + binding.layoutFeatureToggle.switchPaymentDueHint.setOnCheckedChangeListener{ _, isChecked -> + configurationViewModel.setConfiguration( + configurationViewModel.configurationFlow.value.copy( + isPaymentDueHintEnabled = isChecked + ) + ) + } + + // set payment due hint threshold days + binding.layoutFeatureToggle.editTextPaymentDueHintThresholdDays + .doAfterTextChanged { + if (it.toString().isNotEmpty()) { + configurationViewModel.setConfiguration( + configurationViewModel.configurationFlow.value.copy( + paymentDueHintThresholdDays = it.toString().toInt() + ) + ) + } + } + + // enable supported format help screen binding.layoutHelpToggles.switchSupportedFormatsScreen.setOnCheckedChangeListener { _, isChecked -> configurationViewModel.setConfiguration( @@ -618,6 +652,21 @@ class ConfigurationActivity : AppCompatActivity() { ) } + // for internal testing: To simulate the SAF first time experience, in which the picker + // will be shown + binding.layoutFeatureToggle.btnRemoveSafData.setOnClickListener { + SharedPreferenceHelper.saveString(SAF_STORAGE_URI_KEY, "", this) + } + // For testing Save Invoices Locally SDK flag, this is how clients can enable/disable it + binding.layoutFeatureToggle.switchSaveInvoicesLocallyFeature + .setOnCheckedChangeListener { _, isChecked -> + configurationViewModel.setConfiguration( + configurationViewModel.configurationFlow.value.copy( + saveInvoicesLocallyEnabled = isChecked + ) + ) + } + // enable Gini error logger binding.layoutDebugDevelopmentOptionsToggles.switchGiniErrorLogger .setOnCheckedChangeListener { _, isChecked -> diff --git a/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/ConfigurationViewModel.kt b/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/ConfigurationViewModel.kt index 00e57eb225..abd64b6ca3 100644 --- a/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/ConfigurationViewModel.kt +++ b/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/ConfigurationViewModel.kt @@ -34,7 +34,7 @@ import net.gini.android.bank.sdk.exampleapp.ui.adapters.CustomReviewNavigationBa import net.gini.android.bank.sdk.exampleapp.ui.adapters.CustomSkontoHelpNavigationBarBottomAdapter import net.gini.android.bank.sdk.exampleapp.ui.adapters.CustomSkontoNavigationBarBottomAdapter import net.gini.android.bank.sdk.exampleapp.ui.composables.CustomGiniComposableStyleProvider -import net.gini.android.bank.sdk.exampleapp.ui.data.Configuration +import net.gini.android.bank.sdk.exampleapp.ui.data.ExampleAppBankConfiguration import net.gini.android.capture.GiniCaptureDebug import net.gini.android.capture.help.HelpItem import net.gini.android.capture.internal.util.FileImportValidator @@ -59,7 +59,7 @@ class ConfigurationViewModel @Inject constructor( private val _disableCameraPermissionFlow = MutableStateFlow(false) val disableCameraPermissionFlow: StateFlow = _disableCameraPermissionFlow - private val _configurationFlow = MutableStateFlow(Configuration()) + private val _configurationFlow = MutableStateFlow(ExampleAppBankConfiguration()) fun getAlwaysAttachSetting(context: Context): Boolean { configureGiniBank(context) // Gini Bank should be configured before using transactionDocs @@ -77,18 +77,18 @@ class ConfigurationViewModel @Inject constructor( } } - val configurationFlow: StateFlow = _configurationFlow + val configurationFlow: StateFlow = _configurationFlow fun disableCameraPermission(cameraPermission: Boolean) { _disableCameraPermissionFlow.value = cameraPermission } - fun setConfiguration(configuration: Configuration) { + fun setConfiguration(configuration: ExampleAppBankConfiguration) { _configurationFlow.value = configuration } fun setupSDKWithDefaultConfigurations() { - _configurationFlow.value = Configuration.setupSDKWithDefaultConfiguration( + _configurationFlow.value = ExampleAppBankConfiguration.setupSDKWithDefaultConfiguration( configurationFlow.value, CaptureConfiguration(defaultNetworkServicesProvider.defaultNetworkServiceDebugEnabled) ) @@ -122,7 +122,11 @@ class ConfigurationViewModel @Inject constructor( // enable bottom navigation bar bottomNavigationBarEnabled = configuration.isBottomNavigationBarEnabled, // enable payment hints - paymentHintsEnabled = configuration.isPaymentHintsEnabled, + alreadyPaidHintEnabled = configuration.isAlreadyPaidHintEnabled, + // enable payment due hint + paymentDueHintEnabled = configuration.isPaymentDueHintEnabled, + // set payment due hint threshold days + paymentDueHintThresholdDays = configuration.paymentDueHintThresholdDays, // enable onboarding screens at first launch showOnboardingAtFirstRun = configuration.isOnboardingAtFirstRunEnabled, // enable onboarding at every launch @@ -142,6 +146,9 @@ class ConfigurationViewModel @Inject constructor( // enable transaction docs transactionDocsEnabled = configuration.isTransactionDocsEnabled, + + // enables saving invoices locally after analysis + saveInvoicesLocallyEnabled = configuration.saveInvoicesLocallyEnabled, ) // enable Help screens custom bottom navigation bar diff --git a/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/MainActivity.kt b/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/MainActivity.kt index 0174159689..841a4c1801 100644 --- a/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/MainActivity.kt +++ b/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/MainActivity.kt @@ -24,7 +24,7 @@ import net.gini.android.bank.sdk.exampleapp.R import net.gini.android.bank.sdk.exampleapp.core.ExampleUtil.isIntentActionViewOrSend import net.gini.android.bank.sdk.exampleapp.core.PermissionHandler import net.gini.android.bank.sdk.exampleapp.databinding.ActivityMainBinding -import net.gini.android.bank.sdk.exampleapp.ui.data.Configuration +import net.gini.android.bank.sdk.exampleapp.ui.data.ExampleAppBankConfiguration import net.gini.android.bank.sdk.exampleapp.ui.transactiondocs.TransactionDocsActivity import net.gini.android.capture.Document import net.gini.android.capture.EntryPoint @@ -104,7 +104,7 @@ class MainActivity : AppCompatActivity() { when (result.resultCode) { RESULT_CANCELED -> {} RESULT_OK -> { - val configurationResult: Configuration? = result.data?.getParcelableExtra( + val configurationResult: ExampleAppBankConfiguration? = result.data?.getParcelableExtra( CONFIGURATION_BUNDLE ) if (configurationResult != null) { diff --git a/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/data/Configuration.kt b/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/data/ExampleAppBankConfiguration.kt similarity index 91% rename from bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/data/Configuration.kt rename to bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/data/ExampleAppBankConfiguration.kt index 5f1285a233..be39fb6646 100644 --- a/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/data/Configuration.kt +++ b/bank-sdk/example-app/src/main/java/net/gini/android/bank/sdk/exampleapp/ui/data/ExampleAppBankConfiguration.kt @@ -5,11 +5,12 @@ import kotlinx.parcelize.Parcelize import net.gini.android.bank.sdk.capture.CaptureConfiguration import net.gini.android.capture.DocumentImportEnabledFileTypes import net.gini.android.capture.EntryPoint +import net.gini.android.capture.GiniCapture import net.gini.android.capture.internal.util.FileImportValidator @Parcelize -data class Configuration( +data class ExampleAppBankConfiguration( // setup sdk with default configurations val isDefaultSDKConfigurationsEnabled: Boolean = false, @@ -152,7 +153,13 @@ data class Configuration( val isReturnReasonsEnabled: Boolean = false, // enable show warning for paid invoices - val isPaymentHintsEnabled: Boolean = true, + val isAlreadyPaidHintEnabled: Boolean = true, + + // enable payment due hint + val isPaymentDueHintEnabled: Boolean = true, + + // payment due hint threshold days + val paymentDueHintThresholdDays: Int = GiniCapture.PAYMENT_DUE_HINT_THRESHOLD_DAYS, // Digital invoice onboarding custom illustration val isDigitalInvoiceOnboardingCustomIllustrationEnabled: Boolean = false, @@ -190,13 +197,16 @@ data class Configuration( // enable Capture Sdk val isCaptureSDK: Boolean = false, - ) : Parcelable { + // enable/disable save invoices locally feature + val saveInvoicesLocallyEnabled: Boolean = true, + +) : Parcelable { - companion object { + companion object Companion { fun setupSDKWithDefaultConfiguration( - currentConfiguration: Configuration, + currentConfiguration: ExampleAppBankConfiguration, defaultCaptureConfiguration: CaptureConfiguration, - ): Configuration { + ): ExampleAppBankConfiguration { return currentConfiguration.copy( isFileImportEnabled = defaultCaptureConfiguration.fileImportEnabled, isQrCodeEnabled = defaultCaptureConfiguration.qrCodeScanningEnabled, @@ -206,7 +216,9 @@ data class Configuration( isFlashDefaultStateEnabled = defaultCaptureConfiguration.flashOnByDefault, documentImportEnabledFileTypes = defaultCaptureConfiguration.documentImportEnabledFileTypes, isBottomNavigationBarEnabled = defaultCaptureConfiguration.bottomNavigationBarEnabled, - isPaymentHintsEnabled = defaultCaptureConfiguration.paymentHintsEnabled, + isAlreadyPaidHintEnabled = defaultCaptureConfiguration.alreadyPaidHintEnabled, + isPaymentDueHintEnabled = defaultCaptureConfiguration.paymentDueHintEnabled, + paymentDueHintThresholdDays = defaultCaptureConfiguration.paymentDueHintThresholdDays, isOnboardingAtFirstRunEnabled = defaultCaptureConfiguration.showOnboardingAtFirstRun, isOnboardingAtEveryLaunchEnabled = defaultCaptureConfiguration.showOnboarding, isSupportedFormatsHelpScreenEnabled = defaultCaptureConfiguration.supportedFormatsHelpScreenEnabled, @@ -215,7 +227,8 @@ data class Configuration( isAllowScreenshotsEnabled = defaultCaptureConfiguration.allowScreenshots, isSkontoEnabled = defaultCaptureConfiguration.skontoEnabled, isTransactionDocsEnabled = defaultCaptureConfiguration.transactionDocsEnabled, - isCaptureSDK = currentConfiguration.isCaptureSDK + isCaptureSDK = currentConfiguration.isCaptureSDK, + saveInvoicesLocallyEnabled = currentConfiguration.saveInvoicesLocallyEnabled ) } } diff --git a/bank-sdk/example-app/src/main/res/layout/layout_feature_toggles.xml b/bank-sdk/example-app/src/main/res/layout/layout_feature_toggles.xml index a9121d721d..6fcb09ceb3 100644 --- a/bank-sdk/example-app/src/main/res/layout/layout_feature_toggles.xml +++ b/bank-sdk/example-app/src/main/res/layout/layout_feature_toggles.xml @@ -1,6 +1,7 @@ + android:layout_marginTop="@dimen/gc_medium_12" + android:text="@string/save_invoices_feature_label" /> + + + + + + android:layout_marginTop="@dimen/gc_medium_12" + android:text="@string/payment_due_hint_threshold_days_label" + app:layout_constraintTop_toTopOf="parent" /> + + + + + + diff --git a/bank-sdk/example-app/src/main/res/values/strings.xml b/bank-sdk/example-app/src/main/res/values/strings.xml index ca1415e671..707972b722 100644 --- a/bank-sdk/example-app/src/main/res/values/strings.xml +++ b/bank-sdk/example-app/src/main/res/values/strings.xml @@ -59,7 +59,9 @@ Please relaunch the app to use the default GiniConfiguration values. The configuration changes will take effect after closing this screen. SDK default configurations - Payment Hint State + Already Paid Hint + Payment Due Hint + Payment Due Hint Threshold Days Open with QR code scanning QR code scanning only @@ -91,6 +93,7 @@ Onboarding \`lighting\` page custom illustration Onboarding \`QR code\` page custom illustration Onboarding \`multi page\` page custom illustration + Remove SAF Data Onboarding custom bottom navigation bar The custom bottom navigation bar is shown if \`Bottom navigation bar\` is also enabled. @@ -111,7 +114,6 @@ Custom primary button in Skonto Return assistant feature Present a digital representation of the invoice - Toggle to show a warning when an invoice is marked as paid or similar states Skonto feature Transaction Docs feature Return assistant onboarding custom illustration @@ -120,6 +122,7 @@ Return assistant onboarding bottom navigation bar Return assistant bottom navigation bar Event tracker + Save Invoices Locally Return reasons dialog Gini error logger Custom error logger diff --git a/bank-sdk/sdk/build.gradle.kts b/bank-sdk/sdk/build.gradle.kts index ddb11915b8..75b7c936fc 100644 --- a/bank-sdk/sdk/build.gradle.kts +++ b/bank-sdk/sdk/build.gradle.kts @@ -7,11 +7,21 @@ plugins { kotlin("android") id("kotlin-parcelize") id("jacoco") + id ("org.sonarqube") id("androidx.navigation.safeargs.kotlin") alias(libs.plugins.compose.compiler) alias(libs.plugins.devtools.ksp) } +sonar { + properties { + property("sonar.projectKey", "android-bank-sdk") + property("sonar.organization", "gini") + property("sonar.sources", "src/main/java") + property("sonar.host.url", "https://sonarcloud.io") + } +} + jacoco { toolVersion = libs.versions.jacoco.get() } diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/CaptureFlowFragment.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/CaptureFlowFragment.kt index 107007b3ec..9ec9e600e6 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/CaptureFlowFragment.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/CaptureFlowFragment.kt @@ -29,6 +29,7 @@ import net.gini.android.bank.sdk.capture.extractions.skonto.SkontoInvoiceHighlig import net.gini.android.bank.sdk.capture.skonto.SkontoFragment import net.gini.android.bank.sdk.capture.skonto.SkontoFragmentListener import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.model.SkontoInvoiceHighlightBoxes import net.gini.android.bank.sdk.di.getGiniBankKoin import net.gini.android.bank.sdk.transactiondocs.internal.usecase.GetTransactionDocShouldBeAutoAttachedUseCase import net.gini.android.bank.sdk.transactiondocs.internal.usecase.GetTransactionDocsFeatureEnabledUseCase @@ -42,6 +43,8 @@ import net.gini.android.capture.GiniCapture import net.gini.android.capture.GiniCaptureFragment import net.gini.android.capture.GiniCaptureFragmentDirections import net.gini.android.capture.GiniCaptureFragmentListener +import net.gini.android.capture.BankSDKProperties +import net.gini.android.capture.BankSDKBridge import net.gini.android.capture.camera.CameraFragmentListener import net.gini.android.capture.internal.util.CancelListener import net.gini.android.capture.internal.util.ContextHelper @@ -54,12 +57,14 @@ import net.gini.android.capture.tracking.useranalytics.properties.UserAnalyticsU import net.gini.android.capture.ui.theme.GiniTheme import net.gini.android.capture.util.protectViewFromInsets +@Suppress("TooManyFunctions") class CaptureFlowFragment(private val openWithDocument: Document? = null) : Fragment(), GiniCaptureFragmentListener, DigitalInvoiceFragmentListener, SkontoFragmentListener, - CancelListener { + CancelListener, + BankSDKBridge { private lateinit var navController: NavController private lateinit var captureFlowFragmentListener: CaptureFlowFragmentListener @@ -157,7 +162,14 @@ class CaptureFlowFragment(private val openWithDocument: Document? = null) : override fun onCreate(savedInstanceState: Bundle?) { childFragmentManager.fragmentFactory = - CaptureFlowFragmentFactory(this, openWithDocument, this, this, this) + CaptureFlowFragmentFactory( + giniCaptureFragmentListener = this, + bankSDKBridge = this, + openWithDocument = openWithDocument, + digitalInvoiceListener = this, + skontoListener = this, + cancelCallback = this + ) super.onCreate(savedInstanceState) if (GiniCapture.hasInstance() && !GiniCapture.getInstance().allowScreenshots) { requireActivity().window.disallowScreenshots() @@ -169,8 +181,8 @@ class CaptureFlowFragment(private val openWithDocument: Document? = null) : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putBoolean(attachToTransactionDialogStateKey , attachDocumentDialogShowing) - outState.putParcelable(activityResultKey , captureResult) + outState.putBoolean(attachToTransactionDialogStateKey, attachDocumentDialogShowing) + outState.putParcelable(activityResultKey, captureResult) willBeRestored = true } @@ -344,20 +356,7 @@ class CaptureFlowFragment(private val openWithDocument: Document? = null) : } try { - skontoExtractionsHandler.initialize( - result.specificExtractions, - result.compoundExtractions - ) - - val skontoData = skontoDataExtractor.extractSkontoData( - result.specificExtractions, - result.compoundExtractions - ) - - val highlightBoxes = skontoInvoiceHighlightsExtractor.extract( - result.compoundExtractions - ) - + val (skontoData, highlightBoxes) = extractSkontoData(result) navController.navigate( GiniCaptureFragmentDirections.toSkontoFragment( data = skontoData, @@ -369,6 +368,25 @@ class CaptureFlowFragment(private val openWithDocument: Document? = null) : } } + @Throws(NoSuchElementException::class) + fun extractSkontoData(result: CaptureSDKResult.Success): Pair> { + skontoExtractionsHandler.initialize( + result.specificExtractions, + result.compoundExtractions + ) + + val skontoData = skontoDataExtractor.extractSkontoData( + result.specificExtractions, + result.compoundExtractions + ) + + val highlightBoxes = skontoInvoiceHighlightsExtractor.extract( + result.compoundExtractions + ) + return Pair(skontoData, highlightBoxes) + } + + private fun finishWithResult(result: CaptureResult) { if (!ContextHelper.isTablet(requireContext())) { requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED @@ -452,6 +470,32 @@ class CaptureFlowFragment(private val openWithDocument: Document? = null) : ) ) } + + override fun getBankSDKProperties(captureResult: CaptureSDKResult.Success): BankSDKProperties { + val skontoSDKFlag = GiniBank.getCaptureConfiguration()?.skontoEnabled == true + val returnAssistantSDKFlag = + GiniBank.getCaptureConfiguration()?.returnAssistantEnabled == true + val isSkontoExtractionsValid: Boolean = try { + extractSkontoData(captureResult) + true + } catch (_: NoSuchElementException) { + false + } + + val isReturnAssistantExtractionsValid: Boolean = try { + LineItemsValidator.validate(captureResult.compoundExtractions) + true + } catch (_: DigitalInvoiceException) { + false + } + + return BankSDKProperties( + isSkontoSDKFlagEnabled = skontoSDKFlag, + isReturnAssistantSDKFlagEnabled = returnAssistantSDKFlag, + isSkontoExtractionsValid = isSkontoExtractionsValid, + isReturnAssistantExtractionsValid = isReturnAssistantExtractionsValid + ) + } } interface CaptureFlowFragmentListener { @@ -467,6 +511,7 @@ interface CaptureFlowFragmentListener { class CaptureFlowFragmentFactory( private val giniCaptureFragmentListener: GiniCaptureFragmentListener, + private val bankSDKBridge: BankSDKBridge, private var openWithDocument: Document? = null, private val digitalInvoiceListener: DigitalInvoiceFragmentListener, private val skontoListener: SkontoFragmentListener, @@ -481,6 +526,7 @@ class CaptureFlowFragmentFactory( setListener( giniCaptureFragmentListener ) + setBankSDKBridge(bankSDKBridge) } DigitalInvoiceFragment::class.java.name -> DigitalInvoiceFragment().apply { 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 d5199e41ba..b4377e36e4 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 @@ -146,7 +146,21 @@ data class CaptureConfiguration( * * On by default. */ - val paymentHintsEnabled: Boolean = true, + val alreadyPaidHintEnabled: Boolean = true, + + /** + * Enable/disable the payment due hint. + * + * On by default. + */ + val paymentDueHintEnabled: Boolean = true, + + /** + * Set the payment due hint threshold days + * + * 5 by default. + */ + val paymentDueHintThresholdDays: Int = GiniCapture.PAYMENT_DUE_HINT_THRESHOLD_DAYS, /** * Set an adapter implementation to show a custom bottom navigation bar on the onboarding screen. @@ -235,7 +249,12 @@ data class CaptureConfiguration( */ val transactionDocsEnabled: Boolean = false, - val giniComposableStyleProvider: GiniComposableStyleProvider? = null + val giniComposableStyleProvider: GiniComposableStyleProvider? = null, + + /** + * Enable/disable the save invoices locally feature + */ + val saveInvoicesLocallyEnabled: Boolean = true, ) internal fun GiniCapture.Builder.applyConfiguration(configuration: CaptureConfiguration): GiniCapture.Builder { @@ -254,9 +273,12 @@ internal fun GiniCapture.Builder.applyConfiguration(configuration: CaptureConfig .setGiniErrorLoggerIsOn(configuration.giniErrorLoggerIsOn) .setImportedFileSizeBytesLimit(configuration.importedFileSizeBytesLimit) .setBottomNavigationBarEnabled(configuration.bottomNavigationBarEnabled) - .setPaymentHintsEnabled(configuration.paymentHintsEnabled) + .setAlreadyPaidHintEnabled(configuration.alreadyPaidHintEnabled) + .setPaymentDueHintEnabled(configuration.paymentDueHintEnabled) + .setPaymentDueHintThresholdDays(configuration.paymentDueHintThresholdDays) .setEntryPoint(configuration.entryPoint) .setAllowScreenshots(configuration.allowScreenshots) + .setSaveInvoicesLocallyEnabled(configuration.saveInvoicesLocallyEnabled) .addCustomUploadMetadata(GiniBank.USER_COMMENT_GINI_BANK_VERSION, BuildConfig.VERSION_NAME) .apply { configuration.eventTracker?.let { setEventTracker(it) } diff --git a/build.gradle.kts b/build.gradle.kts index 5a74f50cd9..899074ae34 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ import net.gini.gradle.DependencyUpdatesPlugin plugins { alias(libs.plugins.devtools.ksp) apply false + id("org.sonarqube") version "5.1.0.4882" apply false } buildscript { @@ -32,6 +33,17 @@ buildscript { // in the individual module build.gradle.kts files } } +// build.gradle.kts (root) +subprojects { + configurations.configureEach { + resolutionStrategy.eachDependency { + if (requested.group == "org.apache.commons" && requested.name == "commons-compress") { + useVersion("1.26.1") + because("Avoid NoSuchMethodError in Sonar task due to mismatched commons-compress") + } + } + } +} apply() apply() \ No newline at end of file diff --git a/capture-sdk/default-network/build.gradle.kts b/capture-sdk/default-network/build.gradle.kts index 1d52799be3..976c95810f 100644 --- a/capture-sdk/default-network/build.gradle.kts +++ b/capture-sdk/default-network/build.gradle.kts @@ -7,8 +7,17 @@ plugins { id("com.android.library") kotlin("android") id("jacoco") + id ("org.sonarqube") alias(libs.plugins.devtools.ksp) } +sonar { + properties { + property("sonar.projectKey", "android-capture-sdk") + property("sonar.organization", "gini") + property("sonar.sources", "src/main/java") + property("sonar.host.url", "https://sonarcloud.io") + } +} jacoco { toolVersion = libs.versions.jacoco.get() 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 3b051fa746..a9835aa72e 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 @@ -199,12 +199,14 @@ internal constructor( isUserJourneyAnalyticsEnabled = configuration.isUserJourneyAnalyticsEnabled, isSkontoEnabled = configuration.isSkontoEnabled, isReturnAssistantEnabled = configuration.isReturnAssistantEnabled, - isTransactionDocsEnabled = configuration.transactionDocsEnabled, - isQrCodeEducationEnabled = configuration.qrCodeEducationEnabled, - isInstantPaymentEnabled = configuration.instantPaymentEnabled, - paymentHintsEnabled = configuration.paymentHintsEnabled, + isTransactionDocsEnabled = configuration.isTransactionDocsEnabled, + isQrCodeEducationEnabled = configuration.isQrCodeEducationEnabled, + isInstantPaymentEnabled = configuration.isInstantPaymentEnabled, + isAlreadyPaidHintEnabled = configuration.isAlreadyPaidHintEnabled, + isPaymentDueHintEnabled = configuration.isPaymentDueHintEnabled, isEInvoiceEnabled = configuration.isEInvoiceEnabled, amplitudeApiKey = configuration.amplitudeApiKey ?: "", + isSavePhotosLocallyEnabled = configuration.isSavePhotosLocallyEnabled ) @Suppress("LongMethod") 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 b2b049f5ec..c302391817 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,20 +2,23 @@ package net.gini.android.capture.network import android.net.Uri import android.os.Looper -import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import io.mockk.* +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk import net.gini.android.bank.api.BankApiDocumentManager import net.gini.android.bank.api.GiniBankAPI import net.gini.android.capture.Document +import net.gini.android.capture.internal.network.Configuration import net.gini.android.capture.tracking.useranalytics.UserAnalytics import net.gini.android.core.api.Resource import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf -import java.util.* +import java.util.Date +import net.gini.android.bank.api.models.Configuration as BankConfig /** * Created by Alpár Szotyori on 25.02.22. @@ -59,23 +62,42 @@ class GiniCaptureDefaultNetworkServiceTest { // Mock DocumentTaskManager returning the mock documents val documentManager = mockk() - coEvery { documentManager.createPartialDocument(any(), any(), null, null, any()) } returns Resource.Success(partialDocument) - coEvery { documentManager.createCompositeDocument(any>(), any()) } returns Resource.Success(compositeDocument) + 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()) + coEvery { documentManager.getAllExtractionsWithPolling(any()) } returns Resource.Success( + mockk() + ) // Mock GiniBankAPI val bankApi = mockk() every { bankApi.documentManager } returns documentManager - val networkService = GiniCaptureDefaultNetworkService(bankApi, null, ApplicationProvider.getApplicationContext()) + val networkService = GiniCaptureDefaultNetworkService( + bankApi, + null, + 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 "" + every { captureDocument.generateUploadMetadata(getApplicationContext()) } returns "" // When networkService.upload(captureDocument, mockk(relaxed = true)) @@ -91,4 +113,104 @@ class GiniCaptureDefaultNetworkServiceTest { // Then assertThat(networkService.analyzedGiniApiDocument).isEqualTo(compositeDocument) } + + @Test + fun `getConfiguration returns success and maps configuration`() { + val documentManager = mockk() + + val bankConfig = BankConfig( + "test-client-id", + isUserJourneyAnalyticsEnabled = true, + isSkontoEnabled = true, + isReturnAssistantEnabled = true, + amplitudeApiKey = "amplitude", + isTransactionDocsEnabled = true, + isInstantPaymentEnabled = true, + isEInvoiceEnabled = true, + isQrCodeEducationEnabled = true, + isSavePhotosLocallyEnabled = true, + isAlreadyPaidHintEnabled = true, + isPaymentDueHintEnabled = true + ) + + // Add more stubs if mapBankConfigurationToConfiguration uses other properties + coEvery { documentManager.getConfigurations() } returns Resource.Success(bankConfig) + val bankApi = mockk() + every { bankApi.documentManager } returns documentManager + + val service = GiniCaptureDefaultNetworkService(bankApi, null, mockk()) + var called = false + + service.getConfiguration(object : GiniCaptureNetworkCallback { + override fun success(result: Configuration) { + called = true + assertThat(result).isNotNull() + } + + override fun failure(error: Error) { + error("Should not fail") + } + + override fun cancelled() { + error("Should not cancel") + } + }) + shadowOf(Looper.getMainLooper()).idle() + assertThat(called).isTrue() + } + + @Test + fun `getConfiguration returns error and calls failure`() { + val documentManager = mockk(relaxed = true) + coEvery { documentManager.getConfigurations() } returns Resource.Error(message = "fail") + val bankApi = mockk(relaxed = true) { + every { this@mockk.documentManager } returns documentManager + } + val service = GiniCaptureDefaultNetworkService(bankApi, null, mockk()) + var called = false + + service.getConfiguration(object : GiniCaptureNetworkCallback { + override fun success(result: Configuration) { + error("Should not succeed") + } + + override fun failure(error: Error) { + called = true + assertThat(error).isNotNull() + } + + override fun cancelled() { + error("Should not cancel") + } + }) + shadowOf(Looper.getMainLooper()).idle() + assertThat(called).isTrue() + } + + @Test + fun `getConfiguration returns cancelled and calls cancelled`() { + val documentManager = mockk(relaxed = true) + coEvery { documentManager.getConfigurations() } returns Resource.Cancelled() + val bankApi = mockk(relaxed = true) { + every { this@mockk.documentManager } returns documentManager + } + val service = GiniCaptureDefaultNetworkService(bankApi, null, mockk()) + var called = false + + service.getConfiguration(object : GiniCaptureNetworkCallback { + override fun success(result: Configuration) { + error("Should not succeed") + } + + override fun failure(error: Error) { + error("Should not fail") + } + + override fun cancelled() { + called = true + } + }) + shadowOf(Looper.getMainLooper()).idle() + assertThat(called).isTrue() + } } \ No newline at end of file diff --git a/capture-sdk/sdk/build.gradle.kts b/capture-sdk/sdk/build.gradle.kts index 7fc531f536..c55372fc70 100644 --- a/capture-sdk/sdk/build.gradle.kts +++ b/capture-sdk/sdk/build.gradle.kts @@ -7,10 +7,20 @@ plugins { kotlin("android") id("kotlin-parcelize") id("jacoco") + id ("org.sonarqube") id("androidx.navigation.safeargs") alias(libs.plugins.compose.compiler) } +sonar { + properties { + property("sonar.projectKey", "android-capture-sdk") + property("sonar.organization", "gini") + property("sonar.sources", "src/main/java") + property("sonar.host.url", "https://sonarcloud.io") + } +} + jacoco { toolVersion = libs.versions.jacoco.get() } @@ -190,7 +200,8 @@ dependencies { androidTestImplementation(libs.androidx.test.uiautomator) androidTestImplementation(libs.mockito.android) androidTestImplementation(libs.androidx.multidex) - + testImplementation(libs.mockito.kotlin2) + androidTestImplementation(libs.mockito.kotlin2) androidTestUtil(libs.androidx.test.orchestrator) } diff --git a/capture-sdk/sdk/src/androidTest/java/net/gini/android/capture/GiniCaptureHelperForInstrumentationTests.java b/capture-sdk/sdk/src/androidTest/java/net/gini/android/capture/GiniCaptureHelperForInstrumentationTests.java new file mode 100644 index 0000000000..a4dd6c01a4 --- /dev/null +++ b/capture-sdk/sdk/src/androidTest/java/net/gini/android/capture/GiniCaptureHelperForInstrumentationTests.java @@ -0,0 +1,13 @@ +package net.gini.android.capture; + +import androidx.annotation.Nullable; + +/** + * Helper class to set the {@link GiniCapture} instance for instrumentation tests. + */ +public class GiniCaptureHelperForInstrumentationTests { + public static void setGiniCaptureInstance(@Nullable final GiniCapture giniCapture) { + GiniCapture.setInstance(giniCapture); + } + +} diff --git a/capture-sdk/sdk/src/androidTest/java/net/gini/android/capture/ginicapture/GiniCaptureFragmentTest.kt b/capture-sdk/sdk/src/androidTest/java/net/gini/android/capture/ginicapture/GiniCaptureFragmentTest.kt new file mode 100644 index 0000000000..a6e36fb97c --- /dev/null +++ b/capture-sdk/sdk/src/androidTest/java/net/gini/android/capture/ginicapture/GiniCaptureFragmentTest.kt @@ -0,0 +1,233 @@ +package net.gini.android.capture.ginicapture + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import androidx.fragment.app.testing.FragmentScenario +import androidx.lifecycle.Lifecycle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import jersey.repackaged.jsr166e.CompletableFuture +import net.gini.android.capture.DocumentImportEnabledFileTypes +import net.gini.android.capture.EntryPoint +import net.gini.android.capture.GiniCapture +import net.gini.android.capture.GiniCaptureFragment +import net.gini.android.capture.GiniCaptureHelperForInstrumentationTests +import net.gini.android.capture.di.CaptureSdkIsolatedKoinContext +import net.gini.android.capture.internal.document.ImageMultiPageDocumentMemoryStore +import net.gini.android.capture.internal.network.Configuration +import net.gini.android.capture.internal.network.ConfigurationNetworkResult +import net.gini.android.capture.internal.network.NetworkRequestsManager +import net.gini.android.capture.internal.provider.GiniBankConfigurationProvider +import net.gini.android.capture.tracking.useranalytics.BufferedUserAnalyticsEventTracker +import net.gini.android.capture.tracking.useranalytics.UserAnalytics +import net.gini.android.capture.view.DefaultLoadingIndicatorAdapter +import net.gini.android.capture.view.DefaultNavigationBarTopAdapter +import net.gini.android.capture.view.InjectedViewAdapterInstance +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.dsl.module +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import net.gini.android.capture.di.getGiniCaptureKoin +import java.util.UUID + +/** + * Integration test to verify the correct behavior of Analytics. + * Classes involved + * - [GiniCaptureFragment] + * - [GiniCapture] + * - [GiniCapture.Internal] + * - [NetworkRequestsManager] + * - [BufferedUserAnalyticsEventTracker] + * */ + +@RunWith(AndroidJUnit4::class) +class GiniCaptureFragmentTest { + private lateinit var networkRequestsManager: NetworkRequestsManager + private lateinit var giniCapture: GiniCapture + private lateinit var giniInternal: GiniCapture.Internal + private lateinit var memoryStore: ImageMultiPageDocumentMemoryStore + private val koinTestModule = module { + single { GiniBankConfigurationProvider() } + } + + /** + * We are using multiple dependencies in different classes, so many mocks are needed + * to fully test the functionality of AnalyticsTracker, by running the GiniCaptureFragment + * in Isolation. + * Mock was needed for [NetworkRequestsManager], [GiniCapture], [GiniCapture.Internal], + * [ImageMultiPageDocumentMemoryStore], [GiniBankConfigurationProvider] + * Also, in [GiniCaptureFragment], we are using koin to update + * the [GiniBankConfigurationProvider], for that we need to load and unload the module, + * other wise an exception will be thrown from koin for not loaded module. + * In the end, we need to set the mocked [GiniCapture] instance, and we have a helper class + * [GiniCaptureHelperForInstrumentationTests] for that. + * + * */ + + @Before + fun setUp() { + CaptureSdkIsolatedKoinContext.koin.loadModules(listOf(koinTestModule)) + + networkRequestsManager = mock() + giniCapture = mock() + giniInternal = mock() + memoryStore = mock() + + whenever(giniInternal.networkRequestsManager).thenReturn(networkRequestsManager) + whenever(giniCapture.internal()).thenReturn(giniInternal) + whenever(giniCapture.entryPoint).thenReturn(EntryPoint.BUTTON) + whenever(giniInternal.imageMultiPageDocumentMemoryStore).thenReturn(memoryStore) + whenever(giniInternal.navigationBarTopAdapterInstance).thenReturn( + InjectedViewAdapterInstance(DefaultNavigationBarTopAdapter()) + ) + whenever(giniCapture.documentImportEnabledFileTypes).thenReturn( + DocumentImportEnabledFileTypes.NONE + ) + whenever(giniCapture.internal().loadingIndicatorAdapterInstance).thenReturn( + InjectedViewAdapterInstance(DefaultLoadingIndicatorAdapter()) + ) + + GiniCaptureHelperForInstrumentationTests.setGiniCaptureInstance(giniCapture) + } + + + /** + * Unload the koin modules which were loaded in the [setUp]. + * */ + + @After + fun tearDown() = CaptureSdkIsolatedKoinContext.koin.unloadModules(listOf(koinTestModule)) + + + @Test + fun analyticsTracker_shouldBeEmpty_whenUserJourneyDisabled() { + + whenever(networkRequestsManager.getConfigurations(any())).thenReturn( + CompletableFuture.completedFuture(getMockedConfiguration(userJourneyEnabled = false)) + ) + + launchGiniCaptureFragment().use { scenario -> + + scenario.moveToState(Lifecycle.State.STARTED) + + scenario.onFragment { _ -> + assertThat(getAnalyticsTracker().getTrackers()).isEmpty() + } + } + } + + @Test + fun savePhotosLocally_shouldBeDisabled_whenConfigurationSetToFalse() { + whenever(networkRequestsManager.getConfigurations(any())).thenReturn( + CompletableFuture.completedFuture( + getMockedConfiguration( + userJourneyEnabled = false, + savePhotosLocallyEnabled = false + ) + ) + ) + + launchGiniCaptureFragment().use { scenario -> + scenario.moveToState(Lifecycle.State.RESUMED) + val giniBankConfigurationProvider = getGiniCaptureKoin().get() + val configuration = giniBankConfigurationProvider.provide() + + assertThat(configuration.isSavePhotosLocallyEnabled).isFalse() + } + } + + @Test + fun savePhotosLocally_shouldBeEnabled_whenConfigurationSetToTrue() { + whenever(networkRequestsManager.getConfigurations(any())).thenReturn( + CompletableFuture.completedFuture( + getMockedConfiguration( + userJourneyEnabled = false, + savePhotosLocallyEnabled = true + ) + ) + ) + + launchGiniCaptureFragment().use { scenario -> + scenario.moveToState(Lifecycle.State.RESUMED) + val giniBankConfigurationProvider = getGiniCaptureKoin().get() + val configuration = giniBankConfigurationProvider.provide() + + assertThat(configuration.isSavePhotosLocallyEnabled).isTrue() + } + } + + + @Test + fun analyticsTracker_shouldNotBeEmpty_whenUserJourneyEnabled() { + + whenever(networkRequestsManager.getConfigurations(any())).thenReturn( + CompletableFuture.completedFuture(getMockedConfiguration(userJourneyEnabled = true)) + ) + + launchGiniCaptureFragment().use { scenario -> + + scenario.moveToState(Lifecycle.State.STARTED) + + scenario.onFragment { _ -> + assertThat(getAnalyticsTracker().getTrackers()).isNotEmpty() + } + } + } + + + private fun getMockedConfiguration( + userJourneyEnabled: Boolean, + savePhotosLocallyEnabled: Boolean = false + ): ConfigurationNetworkResult { + val testConfig = Configuration( + id = UUID.randomUUID(), + clientID = TEST_CLIENT_ID, + isUserJourneyAnalyticsEnabled = userJourneyEnabled, + isSkontoEnabled = false, + isReturnAssistantEnabled = false, + isTransactionDocsEnabled = false, + isQrCodeEducationEnabled = false, + isInstantPaymentEnabled = false, + isEInvoiceEnabled = false, + amplitudeApiKey = TEST_API_KEY, + isSavePhotosLocallyEnabled = savePhotosLocallyEnabled, + isPaymentDueHintEnabled = false, + isAlreadyPaidHintEnabled = false + ) + + return ConfigurationNetworkResult(testConfig, UUID.randomUUID()) + } + + private fun getAnalyticsTracker(): BufferedUserAnalyticsEventTracker { + return UserAnalytics.getAnalyticsEventTracker() as BufferedUserAnalyticsEventTracker + } + + /** + * Helper method to launch the [GiniCaptureFragment] in a container, + * needed in all the tests. + * + * */ + + private fun launchGiniCaptureFragment(): FragmentScenario { + return FragmentScenario.launchInContainer( + fragmentClass = GiniCaptureFragment::class.java, + factory = object : FragmentFactory() { + override fun instantiate(classLoader: ClassLoader, className: String): Fragment { + return GiniCaptureFragment.createInstance().apply { + setListener(mock()) + setBankSDKBridge(mock()) + } + } + } + ) + } + + companion object { + private const val TEST_CLIENT_ID = "test-client-id" + private const val TEST_API_KEY = "test-api-key" + } +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/BankSDKBridge.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/BankSDKBridge.kt new file mode 100644 index 0000000000..dabab44efe --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/BankSDKBridge.kt @@ -0,0 +1,17 @@ +package net.gini.android.capture + +/** + * Internal use only. + * + * Interface used by [CaptureFlowFragment] to dispatch Bank SDK properties to Capture SDK. + */ +interface BankSDKBridge { + + /** + * This method is implemented in `CaptureFlowFragment` to send the Bank SDK properties to the Capture SDK. + * + * @param captureResult a success result from the Capture SDK + * @return the Bank SDK properties to be used in the Capture SDK + */ + fun getBankSDKProperties(captureResult: CaptureSDKResult.Success): BankSDKProperties +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/BankSDKProperties.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/BankSDKProperties.kt new file mode 100644 index 0000000000..b8c30d9fa1 --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/BankSDKProperties.kt @@ -0,0 +1,21 @@ +package net.gini.android.capture + +/** + * This class is the container for transferring properties between the Gini Bank SDK and + * Gini Capture SDK and between the Fragments of the Gini Capture SDK. + * + * @param isSkontoSDKFlagEnabled is the Skonto SDK flag inside bank SDK + * @param isReturnAssistantSDKFlagEnabled is the Return Assistant SDK flag inside bank SDK + * @param isSkontoExtractionsValid validates if the extraction in the compound extractions + * contain valid Skonto data + * @param isReturnAssistantExtractionsValid validates if the extraction in the compound extractions + * contain valid Return Assistant data data + * + * Internal use only. + */ +data class BankSDKProperties( + val isSkontoSDKFlagEnabled: Boolean = false, + val isReturnAssistantSDKFlagEnabled: Boolean = false, + val isSkontoExtractionsValid: Boolean = false, + val isReturnAssistantExtractionsValid: Boolean = false + ) 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 feed8510b0..e6ba9decdd 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 @@ -89,6 +89,7 @@ */ public class GiniCapture { + public static final int PAYMENT_DUE_HINT_THRESHOLD_DAYS = 5; private static final Logger LOG = LoggerFactory.getLogger(GiniCapture.class); private static GiniCapture sInstance; private final GiniCaptureNetworkService mGiniCaptureNetworkService; @@ -120,7 +121,9 @@ public class GiniCapture { private final InjectedViewAdapterInstance cameraNavigationBarBottomAdapterInstance; private final InjectedViewAdapterInstance errorNavigationBarBottomAdapterInstance; private final boolean isBottomNavigationBarEnabled; - private final boolean isPaymentHintsEnabled; + private final boolean isAlreadyPaidHintEnabled; + private final boolean isPaymentDueHintEnabled; + private final int paymentDueHintThresholdDays; private final InjectedViewAdapterInstance onboardingAlignCornersIllustrationAdapterInstance; private final InjectedViewAdapterInstance onboardingLightingIllustrationAdapterInstance; private final InjectedViewAdapterInstance onboardingMultiPageIllustrationAdapterInstance; @@ -131,6 +134,7 @@ public class GiniCapture { private final GiniComposableStyleProvider mGiniComposableStyleProvider; private final EntryPoint entryPoint; private final boolean allowScreenshots; + private final boolean saveInvoicesLocallyEnabled; private final Map mCustomUploadMetadata; @@ -428,7 +432,9 @@ private GiniCapture(@NonNull final Builder builder) { helpNavigationBarBottomAdapterInstance = builder.getHelpNavigationBarBottomAdapterInstance(); errorNavigationBarBottomAdapterInstance = builder.getErrorNavigationBarBottomAdapterInstance(); isBottomNavigationBarEnabled = builder.isBottomNavigationBarEnabled(); - isPaymentHintsEnabled = builder.isPaymentHintsEnabled(); + isAlreadyPaidHintEnabled = builder.isAlreadyPaidHintEnabled(); + isPaymentDueHintEnabled = builder.isPaymentDueHintEnabled(); + paymentDueHintThresholdDays = builder.getPaymentDueHintThresholdDays(); onboardingAlignCornersIllustrationAdapterInstance = builder.getOnboardingAlignCornersIllustrationAdapterInstance(); onboardingLightingIllustrationAdapterInstance = builder.getOnboardingLightingIllustrationAdapterInstance(); onboardingMultiPageIllustrationAdapterInstance = builder.getOnboardingMultiPageIllustrationAdapterInstance(); @@ -439,6 +445,7 @@ private GiniCapture(@NonNull final Builder builder) { onButtonLoadingIndicatorAdapterInstance = builder.getOnButtonLoadingIndicatorAdapterInstance(); entryPoint = builder.getEntryPoint(); allowScreenshots = builder.getAllowScreenshots(); + saveInvoicesLocallyEnabled = builder.getSaveInvoicesLocallyEnabled(); mCustomUploadMetadata = builder.getCustomUploadMetadata(); mGiniComposableStyleProvider = builder.getGiniComposableStyleProvider(); } @@ -717,8 +724,16 @@ public boolean isBottomNavigationBarEnabled() { return isBottomNavigationBarEnabled; } - public boolean isPaymentHintsEnabled() { - return isPaymentHintsEnabled; + public boolean isAlreadyPaidHintEnabled() { + return isAlreadyPaidHintEnabled; + } + + public boolean isPaymentDueHintEnabled() { + return isPaymentDueHintEnabled; + } + + public int getPaymentDueHintThresholdDays() { + return paymentDueHintThresholdDays; } @Nullable @@ -800,6 +815,10 @@ public boolean getAllowScreenshots() { return allowScreenshots; } + public boolean getSaveInvoicesEnabled() { + return saveInvoicesLocallyEnabled; + } + /** * Get upload metadata to be added to the HTTP headers * @@ -920,7 +939,9 @@ public void onAnalysisScreenEvent(@NotNull final Event even private InjectedViewAdapterInstance errorNavigationBarBottomAdapterInstance = new InjectedViewAdapterInstance<>(new DefaultErrorNavigationBarBottomAdapter()); private InjectedViewAdapterInstance cameraNavigationBarBottomAdapterInstance = new InjectedViewAdapterInstance<>(new DefaultCameraNavigationBarBottomAdapter()); private boolean isBottomNavigationBarEnabled = false; - private boolean isPaymentHintsEnabled = true; + private boolean isAlreadyPaidHintEnabled = true; + private boolean isPaymentDueHintEnabled = true; + private int paymentDueHintThresholdDays = PAYMENT_DUE_HINT_THRESHOLD_DAYS; private InjectedViewAdapterInstance onboardingAlignCornersIllustrationAdapterInstance; private InjectedViewAdapterInstance onboardingLightingIllustrationAdapterInstance; private InjectedViewAdapterInstance onboardingMultiPageIllustrationAdapterInstance; @@ -931,6 +952,7 @@ public void onAnalysisScreenEvent(@NotNull final Event even private InjectedViewAdapterInstance onButtonLoadingIndicatorAdapterInstance = new InjectedViewAdapterInstance<>(new DefaultOnButtonLoadingIndicatorAdapter()); private EntryPoint entryPoint = Internal.DEFAULT_ENTRY_POINT; private boolean allowScreenshots = true; + private boolean savingInvoicesLocallyEnabled = true; private Map customUploadMetadata; private GiniComposableStyleProvider giniComposableStyleProvider; @@ -1341,8 +1363,18 @@ public Builder setBottomNavigationBarEnabled(final Boolean enabled) { return this; } - public Builder setPaymentHintsEnabled(final Boolean enabled){ - isPaymentHintsEnabled = enabled; + public Builder setAlreadyPaidHintEnabled(final Boolean enabled){ + isAlreadyPaidHintEnabled = enabled; + return this; + } + + public Builder setPaymentDueHintEnabled(final Boolean enabled){ + isPaymentDueHintEnabled = enabled; + return this; + } + + public Builder setPaymentDueHintThresholdDays(final int thresholdDays){ + paymentDueHintThresholdDays = thresholdDays; return this; } @@ -1350,8 +1382,16 @@ private boolean isBottomNavigationBarEnabled() { return isBottomNavigationBarEnabled; } - private boolean isPaymentHintsEnabled(){ - return isPaymentHintsEnabled; + private boolean isAlreadyPaidHintEnabled(){ + return isAlreadyPaidHintEnabled; + } + + private boolean isPaymentDueHintEnabled(){ + return isPaymentDueHintEnabled; + } + + private int getPaymentDueHintThresholdDays(){ + return paymentDueHintThresholdDays; } @NonNull @@ -1496,6 +1536,15 @@ private boolean getAllowScreenshots() { return allowScreenshots; } + public Builder setSaveInvoicesLocallyEnabled(boolean savingInvoicesLocallyEnabled) { + this.savingInvoicesLocallyEnabled = savingInvoicesLocallyEnabled; + return this; + } + + private boolean getSaveInvoicesLocallyEnabled() { + return savingInvoicesLocallyEnabled; + } + public Builder addCustomUploadMetadata(String key, String value) { if (customUploadMetadata == null) { customUploadMetadata = new HashMap<>(); diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/GiniCaptureFragment.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/GiniCaptureFragment.kt index 6d5726ea18..83870224e7 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/GiniCaptureFragment.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/GiniCaptureFragment.kt @@ -49,6 +49,7 @@ class GiniCaptureFragment( CancelListener { private lateinit var navController: NavController + private var bankSDKBridge: BankSDKBridge? = null private var giniCaptureFragmentListener: GiniCaptureFragmentListener? = null private lateinit var oncePerInstallEventStore: OncePerInstallEventStore @@ -79,10 +80,15 @@ class GiniCaptureFragment( this.giniCaptureFragmentListener = listener } + fun setBankSDKBridge(listener: BankSDKBridge?) { + this.bankSDKBridge = listener + } + override fun onCreate(savedInstanceState: Bundle?) { childFragmentManager.fragmentFactory = CaptureFragmentFactory( cameraListener = this, analysisFragmentListener = this, + bankSDKBridge = bankSDKBridge, enterManuallyButtonListener = this, cancelListener = this ) @@ -338,6 +344,7 @@ class GiniCaptureFragment( class CaptureFragmentFactory( private val cameraListener: CameraFragmentListener, private val analysisFragmentListener: AnalysisFragmentListener, + private val bankSDKBridge: BankSDKBridge?, private val enterManuallyButtonListener: EnterManuallyButtonListener, private val cancelListener: CancelListener ) : FragmentFactory() { @@ -355,6 +362,7 @@ class CaptureFragmentFactory( setListener( analysisFragmentListener ) + setBankSDKBridge(bankSDKBridge) setCancelListener(cancelListener) } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragment.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragment.java index aaf7cbb430..ae77bf45c9 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragment.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragment.java @@ -1,13 +1,17 @@ package net.gini.android.capture.analysis; -import static net.gini.android.capture.internal.util.FragmentExtensionsKt.getLayoutInflaterWithGiniCaptureTheme; - import android.app.Activity; import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -15,12 +19,18 @@ import androidx.fragment.app.FragmentManager; import androidx.navigation.NavController; import androidx.navigation.fragment.NavHostFragment; + +import net.gini.android.capture.BankSDKBridge; import net.gini.android.capture.Document; +import net.gini.android.capture.analysis.warning.WarningBottomSheet; import net.gini.android.capture.analysis.warning.WarningType; +import net.gini.android.capture.internal.storage.ImageDiskStore; import net.gini.android.capture.internal.ui.FragmentImplCallback; import net.gini.android.capture.internal.util.AlertDialogHelperCompat; import net.gini.android.capture.internal.util.CancelListener; -import net.gini.android.capture.analysis.warning.WarningBottomSheet; + +import static net.gini.android.capture.analysis.AnalysisFragmentImpl.INVOICE_SAVING_IN_PROGRESS_KEY; +import static net.gini.android.capture.internal.util.FragmentExtensionsKt.getLayoutInflaterWithGiniCaptureTheme; /** * Internal use only. @@ -31,7 +41,9 @@ public class AnalysisFragment extends Fragment implements FragmentImplCallback, private static final String WARNING_TAG = "WarningBottomSheet"; private AnalysisFragmentImpl mFragmentImpl; private AnalysisFragmentListener mListener; + private BankSDKBridge bankSDKBridge; private CancelListener mCancelListener; + private ActivityResultLauncher safFolderIntentLauncher; /** * Internal use only. @@ -41,15 +53,51 @@ public class AnalysisFragment extends Fragment implements FragmentImplCallback, @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); + registerSafFolderSelectionHandler(); mFragmentImpl = createFragmentImpl(); mFragmentImpl.onCreate(savedInstanceState); } + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean( + INVOICE_SAVING_IN_PROGRESS_KEY, + mFragmentImpl.getIsInvoiceSavingInProgress()); + } + + private void registerSafFolderSelectionHandler() { + safFolderIntentLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + this::handleFolderResult + ); + } + + private void handleFolderResult(ActivityResult result) { + if (result.getResultCode() != Activity.RESULT_OK) return; + + Intent data = result.getData(); + if (data == null) return; + + Uri treeUri = data.getData(); + if (treeUri == null) return; + + mFragmentImpl.processSafFolderSelection(treeUri, data); + } + + @Override + public void executeSafIntent(Intent intent) { + safFolderIntentLauncher.launch(intent); + } + @VisibleForTesting AnalysisFragmentImpl createFragmentImpl() { final AnalysisFragmentImpl fragmentImpl = AnalysisFragmentHelper.createFragmentImpl(this, mCancelListener, getArguments()); AnalysisFragmentHelper.setListener(fragmentImpl, getActivity(), mListener); + if (bankSDKBridge != null) { + AnalysisFragmentHelper.setBankSDKBridge(fragmentImpl, getActivity(), bankSDKBridge); + } return fragmentImpl; } @@ -119,6 +167,16 @@ public void setListener(@NonNull final AnalysisFragmentListener listener) { mListener = listener; } + @Override + public void setBankSDKBridge(@Nullable BankSDKBridge bankSDKBridge) { + if (mFragmentImpl != null) { + mFragmentImpl.setBankSDKBridge(bankSDKBridge); + } + if (bankSDKBridge != null) { + this.bankSDKBridge = bankSDKBridge; + } + } + public void setCancelListener(@NonNull final CancelListener cancelListener) { mCancelListener = cancelListener; } @@ -143,20 +201,22 @@ public void setCancelListener(@NonNull final CancelListener cancelListener) { * @return a new instance of the Fragment */ public static AnalysisFragment createInstance(@NonNull final Document document, - @Nullable final String documentAnalysisErrorMessage) { + @Nullable final String documentAnalysisErrorMessage, + final Boolean saveInvoicesLocally) { final AnalysisFragment fragment = new AnalysisFragment(); fragment.setArguments( - AnalysisFragmentHelper.createArguments(document, documentAnalysisErrorMessage)); + AnalysisFragmentHelper.createArguments(document, documentAnalysisErrorMessage, + saveInvoicesLocally)); return fragment; } @Override public void showAlertDialog(@NonNull final String message, - @NonNull final String positiveButtonTitle, - @NonNull final DialogInterface.OnClickListener positiveButtonClickListener, - @Nullable final String negativeButtonTitle, - @Nullable final DialogInterface.OnClickListener negativeButtonClickListener, - @Nullable final DialogInterface.OnCancelListener cancelListener) { + @NonNull final String positiveButtonTitle, + @NonNull final DialogInterface.OnClickListener positiveButtonClickListener, + @Nullable final String negativeButtonTitle, + @Nullable final DialogInterface.OnClickListener negativeButtonClickListener, + @Nullable final DialogInterface.OnCancelListener cancelListener) { final Activity activity = getActivity(); if (activity == null) { return; @@ -190,10 +250,13 @@ private WarningBottomSheet.Listener makeWarningListener(@NonNull Runnable onProc return new WarningBottomSheet.Listener() { @Override public void onCancelAction() { + if (getActivity() != null) ImageDiskStore.clear(getActivity()); + if (mCancelListener != null) { mCancelListener.onCancelFlow(); } } + @Override public void onProceedAction() { onProceed.run(); diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentExtension.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentExtension.kt index 68812b06ba..60afc69b5a 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentExtension.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentExtension.kt @@ -5,17 +5,37 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import net.gini.android.capture.R +import net.gini.android.capture.analysis.paymentDueHint.qrcode.PaymentDueHintContent import net.gini.android.capture.internal.camera.view.education.AnimatedEducationMessageWithIntro import net.gini.android.capture.ui.theme.GiniTheme +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale class AnalysisFragmentExtension { private lateinit var educationView: ComposeView + private lateinit var paymentDueHintView: ComposeView fun bindViews(rootView: View) { educationView = rootView.findViewById(R.id.gc_education_container) + paymentDueHintView = rootView.findViewById(R.id.gc_payment_due_hint_container) } + fun showPaymentDueHint(onDismiss: () -> Unit, dueDate: String) { + paymentDueHintView.visibility = View.VISIBLE + paymentDueHintView.setContent { + GiniTheme { + PaymentDueHintContent( + dueDate = formatDateToGermanStyle(dueDate), + onDismiss = onDismiss + ) + } + } + } + + + fun showEducation(onComplete: () -> Unit) { educationView.visibility = View.VISIBLE educationView.setContent { @@ -34,4 +54,13 @@ class AnalysisFragmentExtension { fun hideEducation() { educationView.visibility = View.GONE } + fun formatDateToGermanStyle(dateString: String): String { + return try { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.getDefault()) + val outputFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.getDefault()) + LocalDate.parse(dateString, inputFormatter).format(outputFormatter) + } catch (_: Exception) { + dateString // fallback if parsing fails + } + } } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentHelper.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentHelper.java index ec51299448..bcc55e13cc 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentHelper.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentHelper.java @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import net.gini.android.capture.BankSDKBridge; import net.gini.android.capture.Document; import net.gini.android.capture.internal.ui.FragmentImplCallback; import net.gini.android.capture.internal.util.CancelListener; @@ -16,13 +17,16 @@ final class AnalysisFragmentHelper { private static final String ARGS_DOCUMENT = "GC_ARGS_DOCUMENT"; + private static final String GC_ARGS_SAVE_INVOICES = "GC_ARGS_SAVE_INVOICES"; private static final String ARGS_DOCUMENT_ANALYSIS_ERROR_MESSAGE = "GC_ARGS_DOCUMENT_ANALYSIS_ERROR_MESSAGE"; public static Bundle createArguments(@NonNull final Document document, - @Nullable final String documentAnalysisErrorMessage) { + @Nullable final String documentAnalysisErrorMessage, + final Boolean saveInvoices) { final Bundle arguments = new Bundle(); arguments.putParcelable(ARGS_DOCUMENT, document); + arguments.putBoolean(GC_ARGS_SAVE_INVOICES, saveInvoices); if (documentAnalysisErrorMessage != null) { arguments.putString(ARGS_DOCUMENT_ANALYSIS_ERROR_MESSAGE, documentAnalysisErrorMessage); } @@ -32,10 +36,11 @@ public static Bundle createArguments(@NonNull final Document document, static AnalysisFragmentImpl createFragmentImpl(@NonNull final FragmentImplCallback fragment, @NonNull CancelListener cancelListener, @NonNull final Bundle arguments) { final Document document = arguments.getParcelable(ARGS_DOCUMENT); + final Boolean isInvoiceSavingEnabled = arguments.getBoolean(GC_ARGS_SAVE_INVOICES, false); if (document != null) { final String analysisErrorMessage = arguments.getString( ARGS_DOCUMENT_ANALYSIS_ERROR_MESSAGE); - return new AnalysisFragmentImpl(fragment, cancelListener, document, analysisErrorMessage); + return new AnalysisFragmentImpl(fragment, cancelListener, document, analysisErrorMessage, isInvoiceSavingEnabled); } else { throw new IllegalStateException( "AnalysisFragmentCompat requires a Document. Use the createInstance() method of these classes for instantiating."); @@ -56,6 +61,20 @@ public static void setListener(@NonNull final AnalysisFragmentImpl fragmentImpl, } } + public static void setBankSDKBridge(@NonNull final AnalysisFragmentImpl fragmentImpl, + @NonNull final Context context, @Nullable final BankSDKBridge bankSDKBridge) { + if (context instanceof BankSDKBridge) { + fragmentImpl.setBankSDKBridge((BankSDKBridge) context); + } else if (bankSDKBridge != null) { + fragmentImpl.setBankSDKBridge(bankSDKBridge); + } else { + throw new IllegalStateException( + "BankSDKBridge not set. " + + "You can set it with AnalysisFragmentCompat#setBankSDKBridge() or " + + "by making the host activity implement the BankSDKBridge."); + } + } + private AnalysisFragmentHelper() { } } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentImpl.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentImpl.java index 00283601ad..db50e61f66 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentImpl.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentImpl.java @@ -1,11 +1,15 @@ package net.gini.android.capture.analysis; import static net.gini.android.capture.tracking.EventTrackingHelper.trackAnalysisScreenEvent; +import static net.gini.android.capture.util.SharedPreferenceHelper.SAF_STORAGE_URI_KEY; import android.app.Activity; +import android.content.Context; import android.content.DialogInterface; +import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Color; +import android.net.Uri; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -15,6 +19,7 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; +import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; @@ -23,10 +28,12 @@ import androidx.constraintlayout.widget.ConstraintLayout; import androidx.fragment.app.FragmentActivity; +import net.gini.android.capture.BankSDKBridge; import net.gini.android.capture.Document; import net.gini.android.capture.GiniCapture; import net.gini.android.capture.R; import net.gini.android.capture.analysis.education.EducationCompleteListener; +import net.gini.android.capture.analysis.paymentDueHint.PaymentDueHintDismissListener; import net.gini.android.capture.analysis.warning.WarningType; import net.gini.android.capture.error.ErrorFragment; import net.gini.android.capture.error.ErrorType; @@ -42,6 +49,8 @@ import net.gini.android.capture.tracking.useranalytics.UserAnalyticsScreen; import net.gini.android.capture.tracking.useranalytics.properties.UserAnalyticsEventProperty; import net.gini.android.capture.tracking.useranalytics.properties.UserAnalyticsEventSuperProperty; +import net.gini.android.capture.util.SAFHelper; +import net.gini.android.capture.util.SharedPreferenceHelper; import net.gini.android.capture.view.CustomLoadingIndicatorAdapter; import net.gini.android.capture.view.InjectedViewAdapterHolder; import net.gini.android.capture.view.InjectedViewContainer; @@ -53,17 +62,20 @@ import java.util.HashSet; import java.util.List; +import java.util.Objects; import jersey.repackaged.jsr166e.CompletableFuture; import kotlin.Unit; +import static net.gini.android.capture.tracking.EventTrackingHelper.trackAnalysisScreenEvent; + /** * Main logic implementation for analysis UI presented by {@link AnalysisFragment} */ class AnalysisFragmentImpl extends AnalysisScreenContract.View { protected static final Logger LOG = LoggerFactory.getLogger(AnalysisFragmentImpl.class); - + protected static final String INVOICE_SAVING_IN_PROGRESS_KEY = "invoiceSavingInProgress"; private final FragmentImplCallback mFragment; private final CancelListener mCancelListener; private TextView mAnalysisMessageTextView; @@ -78,25 +90,32 @@ class AnalysisFragmentImpl extends AnalysisScreenContract.View { UserAnalyticsEventTracker userAnalyticsEventTracker = UserAnalytics.INSTANCE.getAnalyticsEventTracker(); AnalysisFragmentExtension fragmentExtension = new AnalysisFragmentExtension(); + private boolean isInvoiceSavingInProgress = false; + AnalysisFragmentImpl(final FragmentImplCallback fragment, final CancelListener cancelListener, @NonNull final Document document, - final String documentAnalysisErrorMessage) { + final String documentAnalysisErrorMessage, + final Boolean mIsInvoiceSavingEnabled) { mFragment = fragment; if (mFragment.getActivity() == null) { throw new IllegalStateException("Missing activity for fragment."); } mCancelListener = cancelListener; - createPresenter(mFragment.getActivity(), document, documentAnalysisErrorMessage); // NOPMD - overridable for testing + createPresenter(mFragment.getActivity(), document, + documentAnalysisErrorMessage, + mIsInvoiceSavingEnabled + ); // NOPMD - overridable for testing } @VisibleForTesting void createPresenter(@NonNull final Activity activity, @NonNull final Document document, - final String documentAnalysisErrorMessage) { + final String documentAnalysisErrorMessage, + final Boolean mIsInvoiceSavingEnabled) { addUserAnalyticEvents(document); new AnalysisScreenPresenter(activity, this, document, - documentAnalysisErrorMessage); + documentAnalysisErrorMessage, mIsInvoiceSavingEnabled); } private void addUserAnalyticEvents(@NonNull Document document) { @@ -124,7 +143,12 @@ public void setListener(@NonNull final AnalysisFragmentListener listener) { } @Override - void showScanAnimation() { + public void setBankSDKBridge(BankSDKBridge bankSDKBridge) { + getPresenter().setBankSDKBridge(bankSDKBridge); + } + + @Override + void showScanAnimation(Boolean isSavingInvoicesLocallyEnabled) { mAnalysisMessageTextView.setVisibility(View.VISIBLE); isScanAnimationActive = true; if (injectedLoadingIndicatorContainer != null) @@ -132,6 +156,15 @@ void showScanAnimation() { adapter.onVisible(); return Unit.INSTANCE; }); + + Context context = mFragment.getActivity(); + if (context == null) return; + + int messageResId = isSavingInvoicesLocallyEnabled + ? R.string.gc_analysis_activity_indicator_message_save_invoices_locally + : R.string.gc_analysis_activity_indicator_message; + + mAnalysisMessageTextView.setText(context.getString(messageResId)); } @Override @@ -146,6 +179,7 @@ void hideScanAnimation() { mAnalysisMessageTextView.setVisibility(View.GONE); } + @Override void showEducation(EducationCompleteListener listener) { fragmentExtension.showEducation(() -> { hideEducation(); @@ -154,6 +188,93 @@ void showEducation(EducationCompleteListener listener) { }); } + /** + * - Handles the folder selected by the user and saves files into it. + * - Persists the folder permission via SAF and stores the folder URI in shared preferences. + * - Storing the URI ensures that future saves can bypass the SAF picker dialog and write + * directly to the folder. otherwise, the SAF dialog would appear every time a file is saved. + * + * @param folderUri The URI of the folder selected by the user. + * @param intent The Intent returned from the folder picker containing the folder data. + */ + public void processSafFolderSelection(Uri folderUri, Intent intent) { + Context context = mFragment.getActivity(); + if (context == null) return; + + SAFHelper.INSTANCE.persistFolderPermission(context, intent); + + // saving SAF URI to shared preferences for future use, so the SAF dialog doesn't + // have to be shown every time a file is saved + + SharedPreferenceHelper.INSTANCE.saveString( + SAF_STORAGE_URI_KEY, + folderUri.toString(), context); + + int result = SAFHelper.INSTANCE.saveFilesToFolder( + context , folderUri, + getPresenter().assembleMultiPageDocumentUris() + ); + + notifyUserAboutSafResult(result, context); + } + + private void notifyUserAboutSafResult(int count, @NonNull Context context) { + if (count > 0) + Toast.makeText(context, + context.getString(R.string.gc_invoice_saving_success_toast_text), + Toast.LENGTH_LONG).show(); + } + + /** + * Checks if the app has permission to save files in the given folder URI, selected by the user. + * + * @param folderUri The folder URI to check. + * @return True if the URI is valid and write permission is granted. + */ + private Boolean haveSavePermission(String folderUri) { + return folderUri != null && !folderUri.isEmpty() && + SAFHelper.INSTANCE.hasWritePermission( + Objects.requireNonNull(mFragment.getActivity()), Uri.parse(folderUri)); + } + + private void saveInvoices(String folderUri) { + Context context = mFragment.getActivity(); + if (context == null) return; + + Uri treeUri = Uri.parse(folderUri); + + int result = SAFHelper.INSTANCE.saveFilesToFolder( + context, + treeUri, + getPresenter().assembleMultiPageDocumentUris()); + + notifyUserAboutSafResult(result, context); + + getPresenter().resumeInterruptedFlow(); + isInvoiceSavingInProgress = false; + } + + @Override + void processInvoiceSaving() { + isInvoiceSavingInProgress = true; + getPresenter().updateInvoiceSavingState(isInvoiceSavingInProgress); + + String folderUri = SharedPreferenceHelper.INSTANCE.getString( + SAF_STORAGE_URI_KEY, + Objects.requireNonNull(mFragment.getActivity())); + + Boolean haveSavePermission = haveSavePermission(folderUri); + if (haveSavePermission) + saveInvoices(folderUri); + else { + mFragment.executeSafIntent(SAFHelper.INSTANCE.createFolderPickerIntent()); + } + } + + public Boolean getIsInvoiceSavingInProgress() { + return isInvoiceSavingInProgress; + } + void hideEducation() { fragmentExtension.hideEducation(); } @@ -226,8 +347,17 @@ void showError(String error, Document document) { } @Override - void showPaidWarningThen(@NonNull WarningType warningType, @NonNull Runnable onProceed) { - mFragment.showWarning(warningType, onProceed); + void showAlreadyPaidWarning(@NonNull WarningType warningType, @NonNull Runnable onProceed) { + mFragment.showWarning(warningType, onProceed); + } + + @Override + void showPaymentDueHint(PaymentDueHintDismissListener listener, String dueDate) { + fragmentExtension.showPaymentDueHint(() -> { + listener.onDismiss(); + return Unit.INSTANCE; + }, + dueDate); } @Override @@ -257,8 +387,10 @@ private void rotateDocumentImageView(final int rotationForDisplay) { mImageDocumentView.setRotation(rotationForDisplay); } + // Required by superclass public void onCreate(final Bundle savedInstanceState) { - // Required by superclass, no changes need to remove it. + if (savedInstanceState != null) + isInvoiceSavingInProgress = savedInstanceState.getBoolean(INVOICE_SAVING_IN_PROGRESS_KEY, false); } @@ -358,6 +490,7 @@ private void onBack() { } public void onResume() { + getPresenter().updateInvoiceSavingState(isInvoiceSavingInProgress); getPresenter().start(); } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentInterface.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentInterface.java index 95bec3b632..ecd40c017f 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentInterface.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisFragmentInterface.java @@ -1,9 +1,9 @@ package net.gini.android.capture.analysis; -import android.view.View; - import androidx.annotation.NonNull; +import net.gini.android.capture.BankSDKBridge; + /** * Internal use only. */ @@ -25,4 +25,16 @@ public interface AnalysisFragmentInterface { * @param listener {@link AnalysisFragmentListener} instance */ void setListener(@NonNull final AnalysisFragmentListener listener); + + /** + *

+ * Set a bridge for Bank SDK properties to be used in the analysis of the Capture SDK. + *

+ *

+ * Note: the bridge is expected to be available until the fragment is + * attached to an activity. Make sure to set the bridge before that. + *

+ * @param bankSDKBridge is used to transfer data from Bank SDK to Analysis screen of Capture SDK + */ + void setBankSDKBridge(@NonNull final BankSDKBridge bankSDKBridge); } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisInteractor.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisInteractor.java index d2a71ca029..e366e2622b 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisInteractor.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisInteractor.java @@ -1,9 +1,10 @@ package net.gini.android.capture.analysis; -import static net.gini.android.capture.internal.network.NetworkRequestsManager.isCancellation; - import android.app.Application; +import androidx.annotation.NonNull; + +import net.gini.android.capture.CaptureSDKResult; import net.gini.android.capture.GiniCapture; import net.gini.android.capture.GiniCaptureDebug; import net.gini.android.capture.document.GiniCaptureDocument; @@ -22,10 +23,10 @@ import java.util.List; import java.util.Map; -import androidx.annotation.NonNull; - import jersey.repackaged.jsr166e.CompletableFuture; +import static net.gini.android.capture.internal.network.NetworkRequestsManager.isCancellation; + /** * Created by Alpar Szotyori on 09.05.2019. *

@@ -203,6 +204,21 @@ public static final class ResultHolder { mGiniApiDocumentFileName = giniApiDocumentFilename; } + /** + *

+ * This is a mapper to convert ResultHolder in AnalysisInteractor to CaptureSDKResult.Success. + *

+ * @param resultHolder a result from the AnalysisInteractor + * @return CaptureSDKResult.Success returns the success result of the Capture SDK + */ + public static CaptureSDKResult.Success toCaptureResult(@NonNull final ResultHolder resultHolder) { + return new CaptureSDKResult.Success( + resultHolder.mExtractions, + resultHolder.mCompoundExtractions, + resultHolder.mReturnReasons + ); + } + @NonNull public Result getResult() { return mResult; diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenContract.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenContract.java index 87700a3ef7..da550cf7e8 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenContract.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenContract.java @@ -3,6 +3,7 @@ import android.app.Activity; import android.content.DialogInterface; import android.graphics.Bitmap; +import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -11,6 +12,7 @@ import net.gini.android.capture.GiniCaptureBasePresenter; import net.gini.android.capture.GiniCaptureBaseView; import net.gini.android.capture.analysis.education.EducationCompleteListener; +import net.gini.android.capture.analysis.paymentDueHint.PaymentDueHintDismissListener; import net.gini.android.capture.analysis.warning.WarningType; import net.gini.android.capture.error.ErrorType; import net.gini.android.capture.internal.util.Size; @@ -40,7 +42,7 @@ public Presenter getPresenter() { return mPresenter; } - abstract void showScanAnimation(); + abstract void showScanAnimation(Boolean isSavingInvoicesLocallyEnabled); abstract void hideScanAnimation(); @@ -64,10 +66,12 @@ abstract void showAlertDialog(@NonNull final String message, abstract void showHints(List hints); abstract void showError(String errorMessage, Document document); - abstract void showPaidWarningThen(@NonNull WarningType warningType, @NonNull Runnable onProceed); + abstract void showAlreadyPaidWarning(@NonNull WarningType warningType, @NonNull Runnable onProceed); + abstract void showPaymentDueHint(PaymentDueHintDismissListener listener, String dueDate); abstract void showError(ErrorType errorType, Document document); abstract void showEducation(EducationCompleteListener listener); + abstract void processInvoiceSaving(); } abstract class Presenter extends GiniCaptureBasePresenter implements @@ -79,5 +83,13 @@ abstract class Presenter extends GiniCaptureBasePresenter implements } abstract void finish(); + + abstract void resumeInterruptedFlow(); + + abstract List assembleMultiPageDocumentUris(); + + abstract void updateInvoiceSavingState(Boolean isInProgress); + + abstract void releaseMutexForEducation(); } } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenPresenter.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenPresenter.java index 6e598edb25..20edb6d92e 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenPresenter.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenPresenter.java @@ -1,17 +1,18 @@ package net.gini.android.capture.analysis; import android.app.Activity; +import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import net.gini.android.capture.AsyncCallback; +import net.gini.android.capture.BankSDKBridge; import net.gini.android.capture.Document; import net.gini.android.capture.GiniCapture; import net.gini.android.capture.GiniCaptureError; import net.gini.android.capture.analysis.warning.WarningPaymentState; -import net.gini.android.capture.analysis.warning.WarningType; import net.gini.android.capture.document.DocumentFactory; import net.gini.android.capture.document.GiniCaptureDocument; import net.gini.android.capture.document.GiniCaptureDocumentError; @@ -34,11 +35,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Random; +import java.util.stream.Collectors; +import java.util.concurrent.TimeUnit; import jersey.repackaged.jsr166e.CompletableFuture; import kotlin.Unit; @@ -60,6 +67,7 @@ class AnalysisScreenPresenter extends AnalysisScreenContract.Presenter { static final String PARCELABLE_MEMORY_CACHE_TAG = "ANALYSIS_FRAGMENT"; private static final String EXTRACTION_PAYMENT_STATE = "paymentState"; + private static final String EXTRACTION_PAYMENT_DUE_DATE = "paymentDueDate"; private static final Logger LOG = LoggerFactory.getLogger(AnalysisScreenPresenter.class); @@ -75,14 +83,18 @@ class AnalysisScreenPresenter extends AnalysisScreenContract.Presenter { DocumentRenderer mDocumentRenderer; private boolean mStopped; private boolean mAnalysisCompleted; + private final boolean mIsInvoiceSavingEnabled; + private boolean isSavingInvoicesInProgress = false; + private AnalysisInteractor.ResultHolder successResultHolder; AnalysisScreenPresenter( @NonNull final Activity activity, @NonNull final AnalysisScreenContract.View view, @NonNull final Document document, - @Nullable final String documentAnalysisErrorMessage) { + @Nullable final String documentAnalysisErrorMessage, + @NonNull final Boolean mIsInvoiceSavingEnabled) { this(activity, view, document, documentAnalysisErrorMessage, - new AnalysisInteractor(activity.getApplication())); + new AnalysisInteractor(activity.getApplication()), mIsInvoiceSavingEnabled); } @VisibleForTesting @@ -91,7 +103,8 @@ class AnalysisScreenPresenter extends AnalysisScreenContract.Presenter { @NonNull final AnalysisScreenContract.View view, @NonNull final Document document, @Nullable final String documentAnalysisErrorMessage, - @NonNull final AnalysisInteractor analysisInteractor) { + @NonNull final AnalysisInteractor analysisInteractor, + @NonNull final Boolean mIsInvoiceSavingEnabled) { super(activity, view); view.setPresenter(this); mMultiPageDocument = asMultiPageDocument(document); @@ -101,6 +114,7 @@ class AnalysisScreenPresenter extends AnalysisScreenContract.Presenter { mAnalysisInteractor = analysisInteractor; mHints = generateRandomHintsList(); extension = new AnalysisScreenPresenterExtension(view); + this.mIsInvoiceSavingEnabled = mIsInvoiceSavingEnabled; } private List generateRandomHintsList() { @@ -151,7 +165,7 @@ void clearParcelableMemoryCache() { } private void startScanAnimation() { - getView().showScanAnimation(); + getView().showScanAnimation(mIsInvoiceSavingEnabled); } private void stopScanAnimation() { @@ -163,6 +177,13 @@ public void setListener(@NonNull final AnalysisFragmentListener listener) { extension.setListener(listener); } + + @Override + public void setBankSDKBridge(BankSDKBridge bankSDKBridge) { + extension.setBankSDKBridge(bankSDKBridge); + } + + @VisibleForTesting void clearSavedImages() { ImageDiskStore.clear(getActivity()); @@ -177,7 +198,7 @@ public void start() { createDocumentRenderer(); } clearParcelableMemoryCache(); - getView().showScanAnimation(); + getView().showScanAnimation(mIsInvoiceSavingEnabled); loadDocumentData(); showHintsForImage(); } @@ -294,6 +315,7 @@ public Void apply(final AnalysisInteractor.ResultHolder resultHolder, resultHolder.getDocumentFileName() ); final AnalysisInteractor.Result result = resultHolder.getResult(); + boolean shouldClearImageCaches = true; switch (result) { case SUCCESS_NO_EXTRACTIONS: mAnalysisCompleted = true; @@ -321,12 +343,26 @@ public Void apply(final AnalysisInteractor.ResultHolder resultHolder, if (resultHolder.getExtractions().isEmpty()) { proceedSuccessNoExtractions(); - } else if (shouldShowPaidInvoiceWarning(resultHolder)) { - getView().showPaidWarningThen( - WarningType.DOCUMENT_MARKED_AS_PAID, - () -> proceedWithExtractions(resultHolder) - ); + } else if (shouldShowAlreadyPaidInvoiceWarning(resultHolder)) { + successResultHolder = resultHolder; + shouldClearImageCaches = false; + extension.showAlreadyPaidHint( + mIsInvoiceSavingEnabled, + isSavingInvoicesInProgress, + successResultHolder, + getActivity()); + } else if (shouldShowPaymentDueHint(resultHolder)) { + successResultHolder = resultHolder; + shouldClearImageCaches = false; + extension.showPaymentDueHint( + resultHolder, + extractPaymentDueDateFromExtraction(resultHolder), + mIsInvoiceSavingEnabled, + isSavingInvoicesInProgress, + getActivity()); } else { + successResultHolder = resultHolder; + shouldClearImageCaches = false; proceedWithExtractions(resultHolder); } } @@ -336,7 +372,8 @@ public Void apply(final AnalysisInteractor.ResultHolder resultHolder, throw new UnsupportedOperationException( "Unknown AnalysisInteractor result: " + result); } - if (result != AnalysisInteractor.Result.NO_NETWORK_SERVICE) { + if (result != AnalysisInteractor.Result.NO_NETWORK_SERVICE + && shouldClearImageCaches) { clearSavedImages(); } return null; @@ -349,7 +386,9 @@ private void proceedSuccessNoExtractions() { } private void proceedWithExtractions(AnalysisInteractor.ResultHolder resultHolder) { - extension.proceedWithExtractions(resultHolder); + extension.proceedWithExtractionsWhenEducationFinished( + resultHolder, mIsInvoiceSavingEnabled, isSavingInvoicesInProgress, getActivity() + ); } private void loadDocumentData() { @@ -475,23 +514,129 @@ private void handleAnalysisError(final Throwable throwable) { getView().showError(errorType, mMultiPageDocument); } - private boolean shouldShowPaidInvoiceWarning( + + /** + * We only have to add the files which are captured from camera, That's why we have to filter + * out the files which are imported via picker or they are from "open with" flow. + * */ + @Override + public List assembleMultiPageDocumentUris() { + if (mMultiPageDocument == null || mMultiPageDocument.getDocuments() == null) { + return Collections.emptyList(); + } + + return mMultiPageDocument.getDocuments().stream() + .filter(doc -> doc.getUri() != null) + .filter(doc -> doc.getImportMethod() != Document.ImportMethod.PICKER + && doc.getImportMethod() != Document.ImportMethod.OPEN_WITH) + .map(Document::getUri) + .collect(Collectors.toList()); + } + + @Override + void updateInvoiceSavingState(Boolean isInProgress) { + isSavingInvoicesInProgress = isInProgress; + } + + @Override + void releaseMutexForEducation() { + extension.releaseMutex(); + } + + /** + * Resumes the interrupted processing flow after the user selects a folder via SAF. + *

+ * After a configuration change {@code successResultHolder} will be {@code null}. + * In that case the flow is restarted by calling {@link #start()}, and rest of the + * flow is handled by {@link #proceedWithExtractions(AnalysisInteractor.ResultHolder)}. + * So, this method must not attempt to resume. + * When {@code successResultHolder} is non-null, this method clears saved images and + * continues processing. + */ + @Override + public void resumeInterruptedFlow() { + if (successResultHolder == null) return; + extension.clearSavedImagesAndProceed(successResultHolder, getActivity()); + } + + private boolean shouldShowAlreadyPaidInvoiceWarning( @NonNull final AnalysisInteractor.ResultHolder resultHolder) { // Feature flags / config - final boolean hintsEnabled = extension.getGetPaymentHintsEnabledUseCase().invoke(); + final boolean alreadyPaidHintClientFlagEnabled = extension.getAlreadyPaidHintEnabledUseCase().invoke(); - final boolean showPaidWarningFlag = GiniCapture.hasInstance() && GiniCapture.getInstance().isPaymentHintsEnabled(); + final boolean alreadyPaidHintSDKFlag = GiniCapture.hasInstance() && GiniCapture.getInstance().isAlreadyPaidHintEnabled(); - if (!hintsEnabled || !showPaidWarningFlag) { + if (!alreadyPaidHintClientFlagEnabled || !alreadyPaidHintSDKFlag) { return false; } // Payment state - final Map exts = resultHolder.getExtractions(); - final GiniCaptureSpecificExtraction ps = exts.get(EXTRACTION_PAYMENT_STATE); - final String paymentStateValue = ps != null ? ps.getValue() : null; - final WarningPaymentState state = WarningPaymentState.from(paymentStateValue); - + final WarningPaymentState state = extractPaymentState(resultHolder.getExtractions()); return state.isPaid(); } -} \ No newline at end of file + + private boolean shouldShowPaymentDueHint( + @NonNull final AnalysisInteractor.ResultHolder resultHolder) { + + final boolean paymentDueHintClientFlagEnabled = + extension.getPaymentDueHintEnabledUseCase().invoke(); + + final boolean paymentDueHintSDKFlag = + GiniCapture.hasInstance() && GiniCapture.getInstance().isPaymentDueHintEnabled(); + + if (extension.isRAOrSkontoIncludedInExtractions(resultHolder)) { + return false; + } + + + if (!paymentDueHintClientFlagEnabled || !paymentDueHintSDKFlag) { + return false; + } + + String paymentDueDate = extractPaymentDueDateFromExtraction(resultHolder); + if (paymentDueDate.isEmpty()) { + return false; + } + + if (calculateRemainingDays(paymentDueDate) < GiniCapture.getInstance().getPaymentDueHintThresholdDays()) { + return false; + } + + + final Map extractions = resultHolder.getExtractions(); + // Payment state + final WarningPaymentState state = extractPaymentState(extractions); + + return state.toBePaid(); + } + + + private String extractPaymentDueDateFromExtraction(AnalysisInteractor.ResultHolder resultHolder) { + return resultHolder.getExtractions().get(EXTRACTION_PAYMENT_DUE_DATE) != null ? + resultHolder.getExtractions().get(EXTRACTION_PAYMENT_DUE_DATE).getValue() : ""; + } + + //TODO: check the validity of remaining day + private int calculateRemainingDays(@NonNull final String paymentDueDate) { + try { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + sdf.setLenient(false); + Date dueDate = sdf.parse(paymentDueDate); + Date today = new Date(); + long diffMillis = (dueDate != null ? dueDate.getTime() : 0) - today.getTime(); + return (int) TimeUnit.MILLISECONDS.toDays(diffMillis); + } catch (ParseException e) { + LOG.error("Failed to parse payment due date: " + paymentDueDate, e); + return 0; + } + } + + // extracts the payment state from extractions + private WarningPaymentState extractPaymentState( + @NonNull final Map extractions) { + final GiniCaptureSpecificExtraction ps = extractions.get(EXTRACTION_PAYMENT_STATE); + final String paymentStateValue = ps != null ? ps.getValue() : null; + return WarningPaymentState.from(paymentStateValue); + } + +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenPresenterExtension.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenPresenterExtension.kt index cb239cf21a..fcdd354aa1 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenPresenterExtension.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/AnalysisScreenPresenterExtension.kt @@ -1,5 +1,6 @@ package net.gini.android.capture.analysis +import android.app.Activity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -7,10 +8,14 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import net.gini.android.capture.BankSDKBridge +import net.gini.android.capture.BankSDKProperties import net.gini.android.capture.Document import net.gini.android.capture.GiniCaptureError +import net.gini.android.capture.analysis.AnalysisInteractor.ResultHolder import net.gini.android.capture.analysis.AnalysisScreenContract.View import net.gini.android.capture.analysis.transactiondoc.AttachedToTransactionDocumentProvider +import net.gini.android.capture.analysis.warning.WarningType import net.gini.android.capture.di.getGiniCaptureKoin import net.gini.android.capture.document.GiniCaptureDocument import net.gini.android.capture.document.GiniCaptureDocumentError @@ -18,12 +23,14 @@ import net.gini.android.capture.document.GiniCaptureMultiPageDocument import net.gini.android.capture.internal.qreducation.GetInvoiceEducationTypeUseCase import net.gini.android.capture.internal.qreducation.IncrementInvoiceRecognizedCounterUseCase import net.gini.android.capture.internal.qreducation.model.InvoiceEducationType +import net.gini.android.capture.internal.storage.ImageDiskStore import net.gini.android.capture.internal.util.NullabilityHelper.getListOrEmpty import net.gini.android.capture.internal.util.NullabilityHelper.getMapOrEmpty import net.gini.android.capture.network.model.GiniCaptureCompoundExtraction import net.gini.android.capture.network.model.GiniCaptureReturnReason import net.gini.android.capture.network.model.GiniCaptureSpecificExtraction -import net.gini.android.capture.paymentHints.GetPaymentHintsEnabledUseCase +import net.gini.android.capture.paymentHints.GetAlreadyPaidHintEnabledUseCase +import net.gini.android.capture.paymentHints.GetPaymentDueHintEnabledUseCase import net.gini.android.capture.tracking.AnalysisScreenEvent import net.gini.android.capture.tracking.EventTrackingHelper @@ -33,8 +40,13 @@ internal class AnalysisScreenPresenterExtension( var listener: AnalysisFragmentListener? = null - val getPaymentHintsEnabledUseCase: - GetPaymentHintsEnabledUseCase by getGiniCaptureKoin().inject() + var bankSDKBridge: BankSDKBridge? = null + + val alreadyPaidHintEnabledUseCase: + GetAlreadyPaidHintEnabledUseCase by getGiniCaptureKoin().inject() + + val paymentDueHintEnabledUseCase: + GetPaymentDueHintEnabledUseCase by getGiniCaptureKoin().inject() val lastAnalyzedDocumentProvider: LastAnalyzedDocumentProvider by getGiniCaptureKoin().inject() @@ -54,6 +66,28 @@ internal class AnalysisScreenPresenterExtension( return listener ?: noOpListener } + fun isRAOrSkontoIncludedInExtractions(resultHolder: ResultHolder): Boolean { + val bankSDKProperties: BankSDKProperties? = + bankSDKBridge?.getBankSDKProperties( + ResultHolder.toCaptureResult( + resultHolder + ) + ) + bankSDKProperties?.let { + val isSkontoEnabled = bankSDKProperties.isSkontoSDKFlagEnabled && + bankSDKProperties.isSkontoExtractionsValid + + val isReturnAssistantEnabled = bankSDKProperties.isReturnAssistantSDKFlagEnabled && + bankSDKProperties.isReturnAssistantExtractionsValid + + if (isSkontoEnabled || isReturnAssistantEnabled) { + return true + } + } + + return false + } + fun proceedSuccessNoExtractions( document: GiniCaptureMultiPageDocument ) { @@ -64,14 +98,133 @@ internal class AnalysisScreenPresenterExtension( } } - fun proceedWithExtractions(resultHolder: AnalysisInteractor.ResultHolder) { - doWhenEducationFinished { - getAnalysisFragmentListenerOrNoOp() - .onExtractionsAvailable( - getMapOrEmpty(resultHolder.extractions), - getMapOrEmpty(resultHolder.compoundExtractions), - getListOrEmpty(resultHolder.returnReasons) + /** + * Continues the invoice extraction flow depending on whether the education screen + * has already been shown. + * + * If `isSavingInvoicesInProgress` is true, it means the education step was already + * completed and only the local invoice saving process is pending. In that case, + * saving resumes immediately and the result will be returned to the customer afterward. + * + * If false, the education screen has not been shown yet. After education finishes, + * the local invoice saving process will start. + */ + + fun proceedWithExtractionsWhenEducationFinished( + resultHolder: ResultHolder, + mIsInvoiceSavingEnabled: Boolean, + isSavingInvoicesInProgress: Boolean, + activity: Activity + ) { + if (isSavingInvoicesInProgress) { + handleSaveInvoicesLocally( + mIsInvoiceSavingEnabled, + true, + resultHolder, + activity + ) + } else { + doWhenEducationFinished { + handleSaveInvoicesLocally( + mIsInvoiceSavingEnabled, + false, + resultHolder, + activity + ) + } + } + } + + fun proceedWithExtractions(resultHolder: ResultHolder) { + getAnalysisFragmentListenerOrNoOp() + .onExtractionsAvailable( + getMapOrEmpty(resultHolder.extractions), + getMapOrEmpty(resultHolder.compoundExtractions), + getListOrEmpty(resultHolder.returnReasons) + ) + } + + fun showAlreadyPaidHint( + mIsInvoiceSavingEnabled: Boolean, + isSavingInvoicesInProgress: Boolean, + resultHolder: ResultHolder, + activity: Activity + ) { + if (isSavingInvoicesInProgress) { + handleSaveInvoicesLocally( + mIsInvoiceSavingEnabled, + true, + resultHolder, + activity + ) + } else { + doWhenEducationFinished { + view.showAlreadyPaidWarning( + WarningType.DOCUMENT_MARKED_AS_PAID + ) { + handleSaveInvoicesLocally( + mIsInvoiceSavingEnabled, + false, + resultHolder, + activity + ) + } + } + } + } + + private fun handleSaveInvoicesLocally( + mIsInvoiceSavingEnabled: Boolean, + isSavingInvoicesInProgress: Boolean, + resultHolder: ResultHolder, + activity: Activity + ) { + if (!mIsInvoiceSavingEnabled || isSavingInvoicesInProgress) { + clearSavedImagesAndProceed( + resultHolder, activity + ) + return + } else { + view.processInvoiceSaving() + } + } + + fun clearSavedImagesAndProceed( + resultHolder: ResultHolder, + activity: Activity + ) { + ImageDiskStore.clear(activity) + proceedWithExtractions(resultHolder) + } + + fun showPaymentDueHint( + resultHolder: ResultHolder, + dueDate: String, + mIsInvoiceSavingEnabled: Boolean, + isSavingInvoicesInProgress: Boolean, + activity: Activity + ) { + if (isSavingInvoicesInProgress) { + handleSaveInvoicesLocally( + mIsInvoiceSavingEnabled, + true, + resultHolder, + activity + ) + } else { + doWhenEducationFinished { + view.showPaymentDueHint( + { + handleSaveInvoicesLocally( + mIsInvoiceSavingEnabled, + false, + resultHolder, + activity + ) + }, + dueDate ) + } } } @@ -96,6 +249,10 @@ internal class AnalysisScreenPresenterExtension( } } + fun releaseMutex() { + if (educationMutex.isLocked) educationMutex.unlock() + } + private fun doWhenEducationFinished(action: () -> Unit) { CoroutineScope(Dispatchers.IO).launch { educationMutex.withLock { diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/paymentDueHint/PaymentDueHintDismissListener.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/paymentDueHint/PaymentDueHintDismissListener.kt new file mode 100644 index 0000000000..d82af66827 --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/paymentDueHint/PaymentDueHintDismissListener.kt @@ -0,0 +1,5 @@ +package net.gini.android.capture.analysis.paymentDueHint + +fun interface PaymentDueHintDismissListener { + fun onDismiss() +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/paymentDueHint/colors/PaymentDueHintColors.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/paymentDueHint/colors/PaymentDueHintColors.kt new file mode 100644 index 0000000000..07c2568ef5 --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/paymentDueHint/colors/PaymentDueHintColors.kt @@ -0,0 +1,39 @@ +package net.gini.android.capture.analysis.paymentDueHint.colors + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import net.gini.android.capture.ui.theme.GiniTheme + +@Immutable +class PaymentDueHintColors( + //tip colors + val tipBackgroundColor: Color, + val tipContentWarningColor: Color, + //button + val buttonTextColor: Color, + val buttonProgressBarColor: Color, + val buttonBorderColor: Color, + val buttonBackgroundColor: Color, +) { + + companion object { + @Composable + fun colors( + tipBackgroundColor: Color = GiniTheme.colorScheme.card.containerWarning, + tipContentWarningColor: Color = GiniTheme.colorScheme.card.contentWarning, + buttonTextColor: Color = GiniTheme.colorScheme.progressBarButton.content, + buttonProgressBarColor: Color = GiniTheme.colorScheme.progressBarButton.progress, + buttonBorderColor: Color = GiniTheme.colorScheme.progressBarButton.border, + buttonBackgroundColor: Color = GiniTheme.colorScheme.progressBarButton.container, + ) = PaymentDueHintColors( + tipBackgroundColor = tipBackgroundColor, + tipContentWarningColor = tipContentWarningColor, + buttonTextColor = buttonTextColor, + buttonProgressBarColor = buttonProgressBarColor, + buttonBorderColor = buttonBorderColor, + buttonBackgroundColor = buttonBackgroundColor + ) + } +} + diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/paymentDueHint/qrcode/PaymentDueHintContent.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/paymentDueHint/qrcode/PaymentDueHintContent.kt new file mode 100644 index 0000000000..449bc4889e --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/paymentDueHint/qrcode/PaymentDueHintContent.kt @@ -0,0 +1,228 @@ +package net.gini.android.capture.analysis.paymentDueHint.qrcode + + +import android.os.SystemClock +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import net.gini.android.capture.R +import net.gini.android.capture.analysis.paymentDueHint.colors.PaymentDueHintColors +import net.gini.android.capture.ui.compose.GiniScreenPreviewUiModes +import net.gini.android.capture.ui.theme.GiniTheme + +@Composable +fun PaymentDueHintContent( + dueDate: String, + onDismiss: () -> Unit, + screenColorScheme: PaymentDueHintColors = PaymentDueHintColors.colors(), +) { + val maxCardWidth = 360.dp + + Surface { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally) + .widthIn(max = maxCardWidth), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + TipCard(screenColorScheme, dueDate) + Spacer(modifier = Modifier.height(4.dp)) + DismissCard(screenColorScheme, onDismiss) + } + } +} + + +@Composable +fun TipCard( + screenColorScheme: PaymentDueHintColors, + dueDate: String +) { + Card( + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = screenColorScheme.tipBackgroundColor + ), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .border( + width = 1.dp, + color = screenColorScheme.tipContentWarningColor, + shape = RoundedCornerShape(8.dp) + ) + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .size(18.dp), + painter = painterResource(id = R.drawable.gc_alert_triangle_icon), + contentDescription = stringResource(R.string.gc_warning_icon_content_description), + tint = screenColorScheme.tipContentWarningColor + ) + + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(R.string.gc_due_date_hint_tip)) + } + append( + stringResource( + R.string.gc_due_date_hint, + dueDate + ) + ) + }, + color = screenColorScheme.tipContentWarningColor, + modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 4.dp, end = 2.dp), + style = GiniTheme.typography.caption1, + ) + } + } +} + +@Composable +fun DismissCard( + screenColorScheme: PaymentDueHintColors, + onDismiss: () -> Unit, +) { + Card( + shape = RoundedCornerShape(4.dp), + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ), + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp) + .border( + width = 1.dp, + color = screenColorScheme.buttonBorderColor, + shape = RoundedCornerShape(4.dp) + ), + onClick = onDismiss + + ) { + Column( + modifier = Modifier.padding(top = 17.dp, start = 2.dp, end = 2.dp, bottom = 2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.gc_dismiss_message), + color = screenColorScheme.buttonTextColor, + style = GiniTheme.typography.button, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + AnimatedProgressBar(screenColorScheme = screenColorScheme, onFinished = onDismiss) + } + } +} + +@Composable +fun AnimatedProgressBar( + durationMillis: Int = 3_000, + onFinished: () -> Unit = {}, + screenColorScheme: PaymentDueHintColors, +) { + // Persist when the countdown started and whether we've already notified finish + val startedAt = rememberSaveable { SystemClock.elapsedRealtime() } + var finished by rememberSaveable { mutableStateOf(false) } + + // Compute elapsed/remaining and initial progress after any recreation (e.g., rotation) + val elapsed = (SystemClock.elapsedRealtime() - startedAt).coerceAtLeast(0) + val remaining = (durationMillis - elapsed).coerceAtLeast(0).toInt() + val initial = (elapsed.toFloat() / durationMillis).coerceIn(0f, 1f) + + // Drive progress explicitly + val progress = remember { Animatable(initial) } + + LaunchedEffect(remaining) { + // Ensure we're at the correct starting fraction after recreation + progress.snapTo(initial) + + if (!finished) { + if (remaining == 0) { + finished = true + onFinished() + } else { + // Animate only for the time left + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = remaining, easing = LinearEasing) + ) + if (!finished) { + finished = true + onFinished() + } + } + } + } + + LinearProgressIndicator( + progress = { progress.value }, + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(RoundedCornerShape(5.dp)), + color = screenColorScheme.buttonProgressBarColor, + trackColor = screenColorScheme.buttonBorderColor + ) +} + + +@Composable +private fun ScreenReadyStatePreview() { + GiniTheme { + PaymentDueHintContent(onDismiss = { /* no-op */ }, dueDate = "12/12/2023") + } +} + + +@GiniScreenPreviewUiModes +@Composable +fun DismissibleTipScreenPreview() { + ScreenReadyStatePreview() +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/warning/WarningBottomSheet.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/warning/WarningBottomSheet.kt index 235bdffc17..0b24ee10cd 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/warning/WarningBottomSheet.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/warning/WarningBottomSheet.kt @@ -174,7 +174,7 @@ class WarningBottomSheet : BottomSheetDialogFragment() { binding.warningTitle.text = titleText binding.warningDescription.text = descText - binding.warningIcon?.contentDescription = getString(R.string.warning_icon_description) + binding.warningIcon?.contentDescription = getString(R.string.gc_warning_icon_content_description) binding.warningIcon?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES binding.cancelButton.setOnClickListener { diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/warning/WarningPaymentState.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/warning/WarningPaymentState.java index 19a6d46c54..79c096359e 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/warning/WarningPaymentState.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/analysis/warning/WarningPaymentState.java @@ -28,5 +28,17 @@ public static WarningPaymentState from(@Nullable String raw) { } } + /** + * Checks if the payment state indicates that the document is already paid. + * + * @return true if the payment state is {@link #PAID}, false otherwise. + */ public boolean isPaid() { return this == PAID; } + + /** + * Checks if the payment state indicates that the document is to be paid. + * + * @return true if the payment state is {@link #TO_BE_PAID}, false otherwise. + */ + public boolean toBePaid() { return this == TO_BE_PAID; } } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/CaptureSdkIsolatedKoinContext.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/CaptureSdkIsolatedKoinContext.kt index dbac7bc7cc..80da041b56 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/CaptureSdkIsolatedKoinContext.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/CaptureSdkIsolatedKoinContext.kt @@ -12,6 +12,7 @@ object CaptureSdkIsolatedKoinContext { qrEducationModule, invoiceEducationModule, EInvoiceModule, + saveInvoicesLocallyModule, paymentHintsModule ) } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/SaveInvoicesLocallyModule.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/SaveInvoicesLocallyModule.kt new file mode 100644 index 0000000000..7323daa739 --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/SaveInvoicesLocallyModule.kt @@ -0,0 +1,13 @@ +package net.gini.android.capture.di + +import net.gini.android.capture.saveinvoiceslocally.GetSaveInvoicesLocallyFeatureEnabledUseCase +import org.koin.dsl.module + +internal val saveInvoicesLocallyModule = module { + + single { + GetSaveInvoicesLocallyFeatureEnabledUseCase( + giniBankConfigurationProvider = get(), + ) + } +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/paymentHintsModule.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/paymentHintsModule.kt index 141da317fb..247bc46f59 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/paymentHintsModule.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/di/paymentHintsModule.kt @@ -1,12 +1,19 @@ package net.gini.android.capture.di -import net.gini.android.capture.paymentHints.GetPaymentHintsEnabledUseCase +import net.gini.android.capture.paymentHints.GetAlreadyPaidHintEnabledUseCase +import net.gini.android.capture.paymentHints.GetPaymentDueHintEnabledUseCase import org.koin.dsl.module internal val paymentHintsModule = module { factory { - GetPaymentHintsEnabledUseCase( + GetAlreadyPaidHintEnabledUseCase( + giniBankConfigurationProvider = get(), + ) + } + + factory { + GetPaymentDueHintEnabledUseCase( giniBankConfigurationProvider = get(), ) } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/camera/photo/ImmutablePhoto.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/camera/photo/ImmutablePhoto.java index 136ff2a9ce..b8878cca16 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/camera/photo/ImmutablePhoto.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/camera/photo/ImmutablePhoto.java @@ -36,15 +36,18 @@ class ImmutablePhoto implements Photo { int mRotationForDisplay; private final ImageDocument.ImageFormat mImageFormat; private final boolean mIsImported; + private Document.ImportMethod mImportMethod = null; private String mParcelableMemoryCacheTag; ImmutablePhoto(@NonNull final byte[] data, final int orientation, - @NonNull final ImageDocument.ImageFormat imageFormat, final boolean isImported) { + @NonNull final ImageDocument.ImageFormat imageFormat, final boolean isImported, + final Document.ImportMethod importMethod) { mData = data; mRotationForDisplay = orientation; mImageFormat = imageFormat; mIsImported = isImported; mBitmapPreview = createPreview(); + mImportMethod = importMethod; } ImmutablePhoto(@NonNull final ImageDocument imageDocument) { @@ -53,6 +56,7 @@ class ImmutablePhoto implements Photo { mImageFormat = imageDocument.getFormat(); mIsImported = imageDocument.isImported(); mBitmapPreview = createPreview(); + mImportMethod = imageDocument.getImportMethod(); } @Nullable @@ -150,7 +154,7 @@ public Document.Source getSource() { @Override public Document.ImportMethod getImportMethod() { - return null; + return mImportMethod; } @Override diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/camera/photo/MutablePhoto.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/camera/photo/MutablePhoto.java index 77f273039b..53a3b0f522 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/camera/photo/MutablePhoto.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/camera/photo/MutablePhoto.java @@ -41,7 +41,7 @@ class MutablePhoto extends ImmutablePhoto implements Parcelable { @NonNull final Document.Source source, @NonNull final Document.ImportMethod importMethod, @NonNull final ImageDocument.ImageFormat format, final boolean isImported) { - super(data, orientation, format, isImported); + super(data, orientation, format, isImported, importMethod); mContentId = generateUUID(); mDeviceOrientation = deviceOrientation; mDeviceType = deviceType; diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/network/Configuration.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/network/Configuration.kt index d0a6c822a9..f50fc6ddc2 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/network/Configuration.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/network/Configuration.kt @@ -13,5 +13,7 @@ data class Configuration( val isInstantPaymentEnabled: Boolean, val isEInvoiceEnabled: Boolean, val amplitudeApiKey: String, - val paymentHintsEnabled: Boolean, + val isSavePhotosLocallyEnabled: Boolean, + val isAlreadyPaidHintEnabled: Boolean, + val isPaymentDueHintEnabled: Boolean, ) diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/provider/GiniBankConfigurationProvider.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/provider/GiniBankConfigurationProvider.kt index 988614a540..1ebd784831 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/provider/GiniBankConfigurationProvider.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/provider/GiniBankConfigurationProvider.kt @@ -14,7 +14,9 @@ class GiniBankConfigurationProvider { isQrCodeEducationEnabled = false, isInstantPaymentEnabled = false, isEInvoiceEnabled = false, - paymentHintsEnabled = false + isSavePhotosLocallyEnabled = false, + isAlreadyPaidHintEnabled = false, + isPaymentDueHintEnabled = false ) fun provide(): Configuration = configuration diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/ui/FragmentImplCallback.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/ui/FragmentImplCallback.java index de9f27631e..5e61e9fc0f 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/ui/FragmentImplCallback.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/ui/FragmentImplCallback.java @@ -37,6 +37,10 @@ public interface FragmentImplCallback { void startActivityForResult(Intent intent, int requestCode); + // kept it default, so only the fragments that need it have to implement it + default void executeSafIntent(Intent intent) { + /* no-op */ + } void showAlertDialog(@NonNull final String message, @NonNull final String positiveButtonTitle, @NonNull final DialogInterface.OnClickListener positiveButtonClickListener, diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/util/FeatureConfiguration.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/util/FeatureConfiguration.java index ef644f2ccd..80531f8024 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/util/FeatureConfiguration.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/internal/util/FeatureConfiguration.java @@ -42,6 +42,10 @@ public static boolean isMultiPageEnabled() { return GiniCapture.hasInstance() && GiniCapture.getInstance().isMultiPageEnabled(); } + public static boolean isSavingInvoicesLocallyEnabled() { + return GiniCapture.hasInstance() && GiniCapture.getInstance().getSaveInvoicesEnabled(); + } + private FeatureConfiguration() { } } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/paymentHints/GetAlreadyPaidHintEnabledUseCase.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/paymentHints/GetAlreadyPaidHintEnabledUseCase.kt new file mode 100644 index 0000000000..bd8e3e17ba --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/paymentHints/GetAlreadyPaidHintEnabledUseCase.kt @@ -0,0 +1,11 @@ +package net.gini.android.capture.paymentHints + +import net.gini.android.capture.internal.provider.GiniBankConfigurationProvider + + +class GetAlreadyPaidHintEnabledUseCase( + private val giniBankConfigurationProvider: GiniBankConfigurationProvider, +) { + operator fun invoke() = giniBankConfigurationProvider.provide().isAlreadyPaidHintEnabled + +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/paymentHints/GetPaymentHintsEnabledUseCase.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/paymentHints/GetPaymentDueHintEnabledUseCase.kt similarity index 80% rename from capture-sdk/sdk/src/main/java/net/gini/android/capture/paymentHints/GetPaymentHintsEnabledUseCase.kt rename to capture-sdk/sdk/src/main/java/net/gini/android/capture/paymentHints/GetPaymentDueHintEnabledUseCase.kt index 0c2a961352..687ca4f2cc 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/paymentHints/GetPaymentHintsEnabledUseCase.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/paymentHints/GetPaymentDueHintEnabledUseCase.kt @@ -2,10 +2,9 @@ package net.gini.android.capture.paymentHints import net.gini.android.capture.internal.provider.GiniBankConfigurationProvider - -class GetPaymentHintsEnabledUseCase( +class GetPaymentDueHintEnabledUseCase( private val giniBankConfigurationProvider: GiniBankConfigurationProvider, ) { - operator fun invoke() = giniBankConfigurationProvider.provide().paymentHintsEnabled + operator fun invoke() = giniBankConfigurationProvider.provide().isPaymentDueHintEnabled } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/review/multipage/MultiPageReviewFragment.java b/capture-sdk/sdk/src/main/java/net/gini/android/capture/review/multipage/MultiPageReviewFragment.java index d5f6e667bd..6dcfc194ca 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/review/multipage/MultiPageReviewFragment.java +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/review/multipage/MultiPageReviewFragment.java @@ -29,6 +29,7 @@ import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.SnapHelper; +import com.google.android.material.materialswitch.MaterialSwitch; import com.google.android.material.tabs.TabLayout; import net.gini.android.capture.Document; @@ -54,6 +55,7 @@ import net.gini.android.capture.review.multipage.previews.PreviewFragmentListener; import net.gini.android.capture.review.multipage.previews.PreviewPagesAdapter; import net.gini.android.capture.review.multipage.view.ReviewNavigationBarBottomAdapter; +import net.gini.android.capture.saveinvoiceslocally.SaveInvoicesFeatureEvaluator; import net.gini.android.capture.tracking.AnalysisScreenEvent; import net.gini.android.capture.tracking.ReviewScreenEvent; import net.gini.android.capture.tracking.ReviewScreenEvent.UPLOAD_ERROR_DETAILS_MAP_KEY; @@ -102,6 +104,8 @@ public class MultiPageReviewFragment extends Fragment implements PreviewFragment private PreviewPagesAdapter mPreviewPagesAdapter; private RecyclerView mRecyclerView; private Button mButtonNext; + private ConstraintLayout mSaveInvoicesWrapper; + private MaterialSwitch mSaveInvoicesSwitch; private LinearLayout mAddPagesWrapperLayout; private Button mAddPagesButton; private TabLayout mTabIndicator; @@ -294,6 +298,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat setupTopNavigationBar(); + handleViewsForSavingInvoices(); + if (mMultiPageDocument != null) { updateNextButtonVisibility(); initRecyclerView(); @@ -301,6 +307,18 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat } } + private void handleViewsForSavingInvoices() { + if (SaveInvoicesFeatureEvaluator.INSTANCE.shouldShowSaveInvoicesLocallyView()) { + updateSaveInvoicesBackground(); + mSaveInvoicesWrapper.setVisibility(View.VISIBLE); + } else + mSaveInvoicesWrapper.setVisibility(View.GONE); + + mSaveInvoicesSwitch.setOnCheckedChangeListener(( + buttonView, + isChecked) -> updateSaveInvoicesBackground()); + } + private void resetUploadedDocumentsViews() { //Needed to refresh views in the recyclerview @@ -440,6 +458,8 @@ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { private void bindViews(final View view) { mButtonNext = view.findViewById(R.id.gc_button_next); + mSaveInvoicesWrapper = view.findViewById(R.id.gc_save_invoices_wrapper); + mSaveInvoicesSwitch = view.findViewById(R.id.gc_save_invoices_switch); mTabIndicator = view.findViewById(R.id.gc_tab_indicator); mTopAdapterInjectedViewContainer = view.findViewById(R.id.gc_navigation_top_bar); mAddPagesWrapperLayout = view.findViewById(R.id.gc_add_pages_wrapper); @@ -468,6 +488,14 @@ private void setInjectedLoadingIndicatorContainer() { } } + private void updateSaveInvoicesBackground() { + mSaveInvoicesWrapper.setBackgroundResource( + mSaveInvoicesSwitch.isChecked() + ? R.drawable.gc_bg_on_save_invoices_locally + : R.drawable.gc_bg_off_save_invoices_locally + ); + } + private void setReviewNavigationBarBottomAdapter(View view) { if (GiniCapture.hasInstance() && GiniCapture.getInstance().isBottomNavigationBarEnabled()) { @@ -621,6 +649,7 @@ private void deleteDocumentAndUpdateUI(@NonNull final ImageDocument document) { NavHostFragment.findNavController(this).navigate(MultiPageReviewFragmentDirections.toCameraFragmentForFirstPage()); } else { doDeleteDocumentAndUpdateUI(document); + handleViewsForSavingInvoices(); } } @@ -751,7 +780,16 @@ void onNextButtonClicked() { ); } mNextClicked = true; - NavHostFragment.findNavController(this).navigate(MultiPageReviewFragmentDirections.toAnalysisFragment(mMultiPageDocument, "")); + NavHostFragment.findNavController(this).navigate( + MultiPageReviewFragmentDirections.toAnalysisFragment( + mMultiPageDocument, + "", + SaveInvoicesFeatureEvaluator.INSTANCE.shouldSaveInvoicesLocally( + mSaveInvoicesWrapper.getVisibility(), + mSaveInvoicesSwitch.isChecked() + ) + ) + ); } @Override diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/saveinvoiceslocally/GetSaveInvoicesLocallyFeatureEnabledUseCase.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/saveinvoiceslocally/GetSaveInvoicesLocallyFeatureEnabledUseCase.kt new file mode 100644 index 0000000000..bf223d5cf8 --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/saveinvoiceslocally/GetSaveInvoicesLocallyFeatureEnabledUseCase.kt @@ -0,0 +1,10 @@ +package net.gini.android.capture.saveinvoiceslocally + +import net.gini.android.capture.internal.provider.GiniBankConfigurationProvider + +internal class GetSaveInvoicesLocallyFeatureEnabledUseCase ( + private val giniBankConfigurationProvider: GiniBankConfigurationProvider, +) { + operator fun invoke(): Boolean = + giniBankConfigurationProvider.provide().isSavePhotosLocallyEnabled +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/saveinvoiceslocally/SaveInvoicesFeatureEvaluator.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/saveinvoiceslocally/SaveInvoicesFeatureEvaluator.kt new file mode 100644 index 0000000000..f201b13fed --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/saveinvoiceslocally/SaveInvoicesFeatureEvaluator.kt @@ -0,0 +1,68 @@ +package net.gini.android.capture.saveinvoiceslocally + +import android.view.View +import net.gini.android.capture.Document +import net.gini.android.capture.GiniCapture +import net.gini.android.capture.di.getGiniCaptureKoin +import net.gini.android.capture.internal.util.FeatureConfiguration.isSavingInvoicesLocallyEnabled + +/** + * Internal use only. + * Helper class of the Save Invoices Locally feature to determine whether the Save Invoices Locally + * view should be shown, and what to pass to the 'AnalysisFragment' to determine whether the + * invoices should be saved locally or not. + */ + +internal object SaveInvoicesFeatureEvaluator { + + private val getSaveInvoicesLocallyFeatureEnabledUseCase: + GetSaveInvoicesLocallyFeatureEnabledUseCase + get() = getGiniCaptureKoin().get() + + + /** + * These conditions must be met to show the Save Invoices Locally view: + * 1. The Gini Capture SDK instance must be initialized. + * 2. The Save Invoices Locally feature must be enabled in the Gini Capture SDK. + * 3. The Save Invoices Locally 'feature flag' must be enabled. + * 4. There must be at least one valid document in the multi-page document that + * was not imported via the picker or "Open with". + * */ + + fun shouldShowSaveInvoicesLocallyView(): Boolean { + if (!GiniCapture.hasInstance()) return false + + val documents = GiniCapture.getInstance() + .internal() + .imageMultiPageDocumentMemoryStore + .multiPageDocument + ?.documents + .orEmpty() + + val hasValidDocs = documents.any { doc -> + doc.uri != null && + doc.importMethod !in listOf( + Document.ImportMethod.PICKER, + Document.ImportMethod.OPEN_WITH + ) + } + + return isSavingInvoicesLocallyEnabled() && + getSaveInvoicesLocallyFeatureEnabledUseCase.invoke() && + hasValidDocs + } + + /** + * Helper method to evaluate whether the invoices should be saved locally in + * 'AnalysisFragment' or not. + * - If the view in 'MultiPageReviewFragment' is visible which is determined by + * [SaveInvoicesFeatureEvaluator.shouldShowSaveInvoicesLocallyView] + * or the switch is turned on by the user only then the invoices should be saved locally. + * */ + + fun shouldSaveInvoicesLocally( + isViewVisible: Int, + isSwitchOn: Boolean + ): Boolean = (isViewVisible == View.VISIBLE) && isSwitchOn + +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/BufferedUserAnalyticsEventTracker.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/BufferedUserAnalyticsEventTracker.kt index d680fcfa4b..2a46fea71d 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/BufferedUserAnalyticsEventTracker.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/BufferedUserAnalyticsEventTracker.kt @@ -1,6 +1,7 @@ package net.gini.android.capture.tracking.useranalytics import android.content.Context +import androidx.annotation.VisibleForTesting import net.gini.android.capture.internal.network.NetworkRequestsManager import net.gini.android.capture.internal.provider.UniqueIdProvider import net.gini.android.capture.tracking.useranalytics.properties.UserAnalyticsEventProperty @@ -64,52 +65,53 @@ internal class BufferedUserAnalyticsEventTracker( } - override fun setEventSuperProperty(property: Set) { + override fun setEventSuperProperty(property: Set): Boolean { if (!mIsUserJourneyEnabled) - return + return false this.eventSuperProperties.add(property) - trySendEvents() + return trySendEvents() } - override fun setEventSuperProperty(property: UserAnalyticsEventSuperProperty) { - setEventSuperProperty(setOf(property)) + override fun setEventSuperProperty(property: UserAnalyticsEventSuperProperty): Boolean { + return setEventSuperProperty(setOf(property)) } - override fun setUserProperty(userProperties: Set) { + override fun setUserProperty(userProperties: Set): Boolean { if (!mIsUserJourneyEnabled) - return + return false this.userProperties.add(userProperties) - trySendEvents() + return trySendEvents() } - override fun setUserProperty(userProperty: UserAnalyticsUserProperty) { - setUserProperty(setOf(userProperty)) + override fun setUserProperty(userProperty: UserAnalyticsUserProperty): Boolean { + return setUserProperty(setOf(userProperty)) } override fun trackEvent( eventName: UserAnalyticsEvent, properties: Set - ) { + ): Boolean { if (!mIsUserJourneyEnabled) - return + return false events.add(Pair(eventName, properties)) - trySendEvents() + return trySendEvents() } - override fun trackEvent(eventName: UserAnalyticsEvent) { - trackEvent(eventName, emptySet()) + override fun trackEvent(eventName: UserAnalyticsEvent): Boolean { + return trackEvent(eventName, emptySet()) } - override fun flushEvents() { - amplitude?.flushEvents() + override fun flushEvents(): Boolean { + return amplitude?.let { + amplitude?.flushEvents() + } ?: false } - private fun trySendEvents() { - if (!mIsUserJourneyEnabled) - return - if (eventTrackers.isEmpty()) { - LOG.debug("No trackers found. Skipping sending events") - return + private fun trySendEvents(): Boolean { + if (!mIsUserJourneyEnabled || eventTrackers.isEmpty()) { + if (eventTrackers.isEmpty()) + LOG.debug("No trackers found. Skipping sending events") + return false } LOG.debug("${eventTrackers.size} Tracker(s) found. Sending events...") @@ -135,10 +137,14 @@ internal class BufferedUserAnalyticsEventTracker( LOG.debug("Events sent") + return true } private fun everyTracker(block: (UserAnalyticsEventTracker) -> Unit) { eventTrackers.forEach(block) } + @VisibleForTesting + internal fun getTrackers(): Set = eventTrackers.toSet() + } \ No newline at end of file diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/UserAnalyticsEventTracker.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/UserAnalyticsEventTracker.kt index 4b86086224..73f475b310 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/UserAnalyticsEventTracker.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/UserAnalyticsEventTracker.kt @@ -7,19 +7,22 @@ import net.gini.android.capture.tracking.useranalytics.properties.UserAnalyticsU interface UserAnalyticsEventTracker { - fun setEventSuperProperty(property: UserAnalyticsEventSuperProperty) + fun setEventSuperProperty(property: UserAnalyticsEventSuperProperty): Boolean - fun setEventSuperProperty(property: Set) + fun setEventSuperProperty(property: Set): Boolean - fun setUserProperty(userProperty: UserAnalyticsUserProperty) + fun setUserProperty(userProperty: UserAnalyticsUserProperty): Boolean - fun setUserProperty(userProperties: Set) + fun setUserProperty(userProperties: Set): Boolean - fun trackEvent(eventName: UserAnalyticsEvent) + fun trackEvent(eventName: UserAnalyticsEvent): Boolean - fun trackEvent(eventName: UserAnalyticsEvent, properties: Set) + fun trackEvent( + eventName: UserAnalyticsEvent, + properties: Set + ): Boolean - fun flushEvents() + fun flushEvents(): Boolean } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/tracker/AmplitudeUserAnalyticsEventTracker.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/tracker/AmplitudeUserAnalyticsEventTracker.kt index 017b4e0e93..6f257fb449 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/tracker/AmplitudeUserAnalyticsEventTracker.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/tracking/useranalytics/tracker/AmplitudeUserAnalyticsEventTracker.kt @@ -45,24 +45,26 @@ internal class AmplitudeUserAnalyticsEventTracker( context ) - override fun setUserProperty(userProperties: Set) { + override fun setUserProperty(userProperties: Set) : Boolean { this.userProperties = userProperties.associate { it.getPair() } + return true } - override fun setEventSuperProperty(property: UserAnalyticsEventSuperProperty) { - setEventSuperProperty(setOf(property)) + override fun setEventSuperProperty(property: UserAnalyticsEventSuperProperty) : Boolean{ + return setEventSuperProperty(setOf(property)) } - override fun setEventSuperProperty(property: Set) { + override fun setEventSuperProperty(property: Set) : Boolean{ superProperties.addAll(property) + return true } - override fun setUserProperty(userProperty: UserAnalyticsUserProperty) { - setUserProperty(setOf(userProperty)) + override fun setUserProperty(userProperty: UserAnalyticsUserProperty): Boolean { + return setUserProperty(setOf(userProperty)) } - override fun trackEvent(eventName: UserAnalyticsEvent) { - trackEvent(eventName, emptySet()) + override fun trackEvent(eventName: UserAnalyticsEvent) : Boolean{ + return trackEvent(eventName, emptySet()) } private val events: MutableList = mutableListOf() @@ -70,7 +72,7 @@ internal class AmplitudeUserAnalyticsEventTracker( override fun trackEvent( eventName: UserAnalyticsEvent, properties: Set - ) { + ) : Boolean{ val superPropertiesMap = superProperties.associate { it.getPair() } val propertiesMap = properties.associate { it.getPair() } @@ -102,6 +104,7 @@ internal class AmplitudeUserAnalyticsEventTracker( LOG.debug("\nEvent: ${eventName.eventName}\n" + finalProperties.toList().joinToString("\n") { " ${it.first}=${it.second}" }) + return true } fun startRepeatingJob(): Job { @@ -113,10 +116,11 @@ internal class AmplitudeUserAnalyticsEventTracker( } } - override fun flushEvents() { + override fun flushEvents() : Boolean{ CoroutineScope(Dispatchers.IO).launch { sendEventsToAmplitudeApi() } + return true } private fun sendEventsToAmplitudeApi() { diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/theme/colors/GiniColorPalette.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/theme/colors/GiniColorPalette.kt index 24e0118b0d..e71fa63d1d 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/theme/colors/GiniColorPalette.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/theme/colors/GiniColorPalette.kt @@ -21,6 +21,7 @@ data class GiniColorScheme( val badge: Badge = Badge(), val button: Button = Button(), val buttonOutlined: ButtonOutlined = ButtonOutlined(), + val progressBarButton: ProgressBarButton = ProgressBarButton(), val textField: TextField = TextField(), val toggles: Toggles = Toggles(), val dialogs: Dialogs = Dialogs(), @@ -119,6 +120,14 @@ data class GiniColorScheme( val content: Color = Color.Unspecified, ) + @Immutable + data class ProgressBarButton( + val container: Color = Color.Unspecified, + val content: Color = Color.Unspecified, + val border: Color = Color.Unspecified, + val progress: Color = Color.Unspecified, + ) + @Immutable data class TextField( val container: Color = Color.Unspecified, @@ -284,6 +293,12 @@ internal fun giniLightColorScheme( container = light04, content = dark02 ), + progressBarButton = GiniColorScheme.ProgressBarButton( + container = light02, + content = dark02, + border = light04, + progress = accent01 + ), textField = GiniColorScheme.TextField( container = light01, text = GiniColorScheme.TextField.Text( @@ -414,6 +429,12 @@ internal fun giniDarkColorScheme( container = dark04, content = light01 ), + progressBarButton = GiniColorScheme.ProgressBarButton( + container = dark02, + content = light01, + border = light01, + progress = accent01 + ), textField = GiniColorScheme.TextField( container = dark02, text = GiniColorScheme.TextField.Text( diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/SAFHelper.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/SAFHelper.kt new file mode 100644 index 0000000000..b82187d2a2 --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/SAFHelper.kt @@ -0,0 +1,178 @@ +package net.gini.android.capture.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import net.gini.android.capture.R +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + + +/** + * Internal use only. + * SAFHelper + * + * This object helps with saving files using Android's Storage Access Framework (SAF). + * It checks write permissions, opens a folder picker if needed, remembers folder access, + * and saves one or more files into the selected folder. + * + * Used when the app needs to let the user choose a folder and save files. + */ + +internal object SAFHelper { + + val LOG: Logger = LoggerFactory.getLogger(SAFHelper::class.java) + /** + * Checks if the app has write permission for the given folder URI. + * + * @param context The context used to access the content resolver. + * @param folderUri The URI of the folder to check. + * @return True if write permission exists, false otherwise. + */ + + fun hasWritePermission(context: Context, folderUri: Uri): Boolean { + if (directoryExists(context, folderUri).not()) return false + val result = context.contentResolver.persistedUriPermissions.any { + it.uri == folderUri && it.isWritePermission + } + return result + } + + /** + * Checks if the directory exists and is accessible. + * + * @param context The context used to access the content resolver. + * @param folderUri The URI of the folder to check. + * @return True if the directory exists and is accessible, false otherwise. + */ + private fun directoryExists(context: Context, folderUri: Uri): Boolean { + return try { + val documentFile = DocumentFile.fromTreeUri(context, folderUri) + documentFile?.exists() == true && documentFile.isDirectory + } catch (e: SecurityException) { + LOG.error("SecurityException checking directory existence", e) + false + } catch (e: IllegalArgumentException) { + LOG.error("IllegalArgumentException checking directory existence", e) + false + } + } + + /** + * Creates an intent that opens a system folder picker for the user. + * The intent requests both read and write access to the chosen folder. + * + * @return Intent ready to start with startActivityForResult() or ActivityResultLauncher. + */ + + fun createFolderPickerIntent(): Intent { + return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + ) + } + } + + /** + * Saves the user's folder selection permission so it can be reused later + * without asking again. + * + * @param context The context used to access the content resolver. + * @param dataIntent The intent returned from the folder picker. + */ + + fun persistFolderPermission(context: Context, dataIntent: Intent?) { + if (dataIntent == null) return + val folderUri = dataIntent.data ?: return + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + try { + context.contentResolver.takePersistableUriPermission(folderUri, takeFlags) + } catch (e: SecurityException) { + LOG.error("SecurityException in SAF", e) + } + } + + /** + * Saves multiple files to the selected folder. + * + * @param context The context used to access files and resolver. + * @param folderUri The URI of the destination folder. + * @param sourceUris List of URIs of the source files to copy. + * @return Number of files successfully saved. + */ + + fun saveFilesToFolder( + context: Context, + folderUri: Uri, + sourceUris: List, + ): Int = runBlocking { + withContext(Dispatchers.IO) { + val pickedDir = DocumentFile.fromTreeUri(context, folderUri) ?: return@withContext 0 + + val results = sourceUris.mapIndexed { suffixForFileName, uri -> + val fileName = context.getString( + R.string.gc_invoice_file_name, + System.currentTimeMillis(), suffixForFileName + ) + async { saveSingleFile(context, pickedDir, uri, fileName) } + }.awaitAll() + + results.count { it } + } + } + + /** + * Copies one file at a time to the given folder. + * + * @param context The context used to open streams. + * @param folder The DocumentFile representing the target folder. + * @param sourceUri The URI of the source file. + * @return True if the file was saved successfully, false otherwise. + */ + + private fun saveSingleFile( + context: Context, + folder: DocumentFile, + sourceUri: Uri, + fileName: String + ): Boolean { + return try { + val resolver = context.contentResolver + val newFile = folder.createFile("image/jpeg", fileName) + val input = resolver.openInputStream(sourceUri) + val output = newFile?.let { resolver.openOutputStream(it.uri) } + + val success = if (newFile != null && input != null && output != null) { + copyStreams(input, output) + true + } else false + + success + } catch (e: IOException) { + LOG.error("IOException in SAF", e) + false + } catch (e: SecurityException) { + LOG.error("SecurityException in SAF", e) + false + } catch (e: IllegalArgumentException) { + LOG.error("IllegalArgumentException in SAF", e) + false + } + } + + private fun copyStreams(input: InputStream, output: OutputStream) { + input.use { i -> output.use { o -> i.copyTo(o) } } + } +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/SharedPreferenceHelper.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/SharedPreferenceHelper.kt new file mode 100644 index 0000000000..5c2c34f4b6 --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/SharedPreferenceHelper.kt @@ -0,0 +1,25 @@ +package net.gini.android.capture.util + +import android.content.Context +import androidx.core.content.edit + +/** + * Generic class for saving simple data in to the shared preferences. + * In future this could be extended to support other data types as well. + * */ + +object SharedPreferenceHelper { + + private const val PREFS_KEY = "generic_data_preferences" + const val SAF_STORAGE_URI_KEY = "SAF_storage_uri_key" + + fun saveString(key: String, value: String, context: Context) { + val prefs = context.getSharedPreferences(PREFS_KEY, Context.MODE_PRIVATE) + prefs.edit { putString(key, value) } + } + + fun getString(key: String, context: Context): String? { + val prefs = context.getSharedPreferences(PREFS_KEY, Context.MODE_PRIVATE) + return prefs.getString(key, null) + } +} diff --git a/capture-sdk/sdk/src/main/res/color/gc_switch_outline_color.xml b/capture-sdk/sdk/src/main/res/color/gc_switch_outline_color.xml new file mode 100644 index 0000000000..170bdd499f --- /dev/null +++ b/capture-sdk/sdk/src/main/res/color/gc_switch_outline_color.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/capture-sdk/sdk/src/main/res/color/gc_switch_thumb_color.xml b/capture-sdk/sdk/src/main/res/color/gc_switch_thumb_color.xml new file mode 100644 index 0000000000..bc80e878a2 --- /dev/null +++ b/capture-sdk/sdk/src/main/res/color/gc_switch_thumb_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/capture-sdk/sdk/src/main/res/color/gc_switch_track_color.xml b/capture-sdk/sdk/src/main/res/color/gc_switch_track_color.xml new file mode 100644 index 0000000000..15b0ff889a --- /dev/null +++ b/capture-sdk/sdk/src/main/res/color/gc_switch_track_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/capture-sdk/sdk/src/main/res/drawable-night/gc_bg_off_save_invoices_locally.xml b/capture-sdk/sdk/src/main/res/drawable-night/gc_bg_off_save_invoices_locally.xml new file mode 100644 index 0000000000..e3fa1c34ec --- /dev/null +++ b/capture-sdk/sdk/src/main/res/drawable-night/gc_bg_off_save_invoices_locally.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/capture-sdk/sdk/src/main/res/drawable-night/gc_bg_on_save_invoices_locally.xml b/capture-sdk/sdk/src/main/res/drawable-night/gc_bg_on_save_invoices_locally.xml new file mode 100644 index 0000000000..12304d8905 --- /dev/null +++ b/capture-sdk/sdk/src/main/res/drawable-night/gc_bg_on_save_invoices_locally.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/capture-sdk/sdk/src/main/res/drawable/gc_bg_off_save_invoices_locally.xml b/capture-sdk/sdk/src/main/res/drawable/gc_bg_off_save_invoices_locally.xml new file mode 100644 index 0000000000..e3fa1c34ec --- /dev/null +++ b/capture-sdk/sdk/src/main/res/drawable/gc_bg_off_save_invoices_locally.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/capture-sdk/sdk/src/main/res/drawable/gc_bg_on_save_invoices_locally.xml b/capture-sdk/sdk/src/main/res/drawable/gc_bg_on_save_invoices_locally.xml new file mode 100644 index 0000000000..7b793fdb5d --- /dev/null +++ b/capture-sdk/sdk/src/main/res/drawable/gc_bg_on_save_invoices_locally.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/capture-sdk/sdk/src/main/res/layout-land/gc_fragment_multi_page_review.xml b/capture-sdk/sdk/src/main/res/layout-land/gc_fragment_multi_page_review.xml index 3b075f51ae..cddf93a6e2 100644 --- a/capture-sdk/sdk/src/main/res/layout-land/gc_fragment_multi_page_review.xml +++ b/capture-sdk/sdk/src/main/res/layout-land/gc_fragment_multi_page_review.xml @@ -41,6 +41,15 @@ app:layout_constraintStart_toStartOf="@+id/gc_view_pager_wrapper" app:layout_constraintTop_toBottomOf="@id/gc_navigation_top_bar" /> + + + @@ -70,78 +79,152 @@ - - - - -