diff --git a/.github/workflows/merchant-sdk.check.yml b/.github/workflows/merchant-sdk.check.yml new file mode 100644 index 0000000000..1569337013 --- /dev/null +++ b/.github/workflows/merchant-sdk.check.yml @@ -0,0 +1,157 @@ +name: Check Merchant SDK + +on: + push: + paths: + - 'merchant-sdk/**' + - 'health-api-library/**' + - 'core-api-library/**' + - 'gradle/**' + branches: + - '**' + tags-ignore: + - '**' + pull_request: + types: [opened, edited, reopened] + paths: + - 'merchant-sdk/**' + - 'health-api-library/**' + - 'core-api-library/**' + - 'gradle/**' + workflow_call: + secrets: + GINI_MOBILE_TEST_CLIENT_SECRET: + required: true + MERCHANT_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD: + required: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: setup java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: run unit tests + run: ./gradlew merchant-sdk:sdk:testDebugUnitTest + + - name: archive unit test results + if: always() + uses: actions/upload-artifact@v3 + with: + name: merchant-sdk-unit-test-results + path: merchant-sdk/sdk/build/reports/tests + + build-example-app: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: build release example app for QA + run: > + ./gradlew merchant-sdk:example-app:assembleQaRelease + -PclientId="gini-mobile-test" + -PclientSecret="${{ secrets.GINI_MOBILE_TEST_CLIENT_SECRET }}" + -PreleaseKeystoreFile="merchant_sdk_example.jks" + -PreleaseKeystorePassword='${{ secrets.MERCHANT_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD }}' + -PreleaseKeyAlias="merchant_sdk_example" + -PreleaseKeyPassword='${{ secrets.MERCHANT_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD }}' + + - name: archive release example app for QA + uses: actions/upload-artifact@v3 + with: + name: merchant-sdk-example-app-qa-release + path: merchant-sdk/example-app/build/outputs/apk/qa/release + + - name: build release example app for production + run: > + ./gradlew merchant-sdk:example-app:assembleProdRelease + -PclientId="gini-mobile-test" + -PclientSecret="${{ secrets.GINI_MOBILE_TEST_CLIENT_SECRET }}" + -PreleaseKeystoreFile="merchant_sdk_example.jks" + -PreleaseKeystorePassword='${{ secrets.MERCHANT_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD }}' + -PreleaseKeyAlias="merchant_sdk_example" + -PreleaseKeyPassword='${{ secrets.MERCHANT_SDK_EXAMPLE_APP_KEYSTORE_PASSWORD }}' + + - name: archive release example app for production + uses: actions/upload-artifact@v3 + with: + name: merchant-sdk-example-app-prod-release + path: merchant-sdk/example-app/build/outputs/apk/prod/release + + android-lint: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: run android lint + run: ./gradlew merchant-sdk:sdk:lint + + - name: archive android lint report + uses: actions/upload-artifact@v3 + with: + name: merchant-sdk-android-lint-report + path: merchant-sdk/sdk/build/reports/lint-results*.html + + detekt: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: run detekt + run: ./gradlew merchant-sdk:sdk:detekt + + - name: archive detekt report + uses: actions/upload-artifact@v3 + with: + name: merchant-sdk-detekt-report + path: merchant-sdk/sdk/build/reports/detekt/*.html + + ktlint: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: run ktlint + run: ./gradlew merchant-sdk:sdk:ktlintCheck + + - name: archive ktlint report + uses: actions/upload-artifact@v3 + with: + name: merchant-sdk-ktlint-report + path: merchant-sdk/sdk/build/reports/ktlint/**/*.html diff --git a/.github/workflows/merchant-sdk.docs.build.yml b/.github/workflows/merchant-sdk.docs.build.yml new file mode 100644 index 0000000000..40fe3e989c --- /dev/null +++ b/.github/workflows/merchant-sdk.docs.build.yml @@ -0,0 +1,55 @@ +name: Build docs for Merchant SDK + +on: + push: + paths: + - 'merchant-sdk/**' + branches: + - '**' + tags-ignore: + - '**' + pull_request: + types: [opened, edited, reopened] + paths: + - 'merchant-sdk/**' + +jobs: + build-docs: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: setup java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: setup python + uses: actions/setup-python@v4 + with: + python-version: 'pypy2.7' + + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2.0' + bundler-cache: true + + - name: build documentation + uses: maierj/fastlane-action@v3.0.0 + with: + lane: 'build_documentation' + options: > + { + "project_id": "merchant-sdk", + "module_id": "sdk" + } + + - name: archive documentation + uses: actions/upload-artifact@v3 + with: + name: merchant-sdk-documentation + path: merchant-sdk/sdk/build/docs \ No newline at end of file diff --git a/.github/workflows/merchant-sdk.docs.release.yml b/.github/workflows/merchant-sdk.docs.release.yml new file mode 100644 index 0000000000..1b46426435 --- /dev/null +++ b/.github/workflows/merchant-sdk.docs.release.yml @@ -0,0 +1,75 @@ +name: Release docs for Merchant SDK + +on: + push: + tags: + - 'merchant-sdk;[0-9]+.[0-9]+.[0-9]+;doc**' + - 'merchant-sdk;[0-9]+.[0-9]+.[0-9]+-beta[0-9][0-9];doc**' + workflow_call: + secrets: + RELEASE_GITHUB_USER: + required: true + RELEASE_GITHUB_PASSWORD: + required: true + +jobs: + release-docs: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: get branch name + id: branch + shell: bash + run: | + # Get the branch ref that contains the tag in github.ref + # (github.ref contains the tag because this workflow is triggered by tags: + # https://docs.github.com/en/actions/learn-github-actions/contexts#github-context) + branch_ref=$(git branch -r --contains "${{ github.ref }}") + # Remove "origin/" prefix from branch_ref and trim whitespace + branch_name=$(echo ${branch_ref/origin\/} | tr -d '[:space:]') + echo "::set-output name=branch_name::$branch_name" + echo "branch_name: $branch_name" + + - name: setup java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: setup python + uses: actions/setup-python@v4 + with: + python-version: 'pypy2.7' + + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2.0' + bundler-cache: true + + - name: release documentation + uses: maierj/fastlane-action@v3.0.0 + with: + lane: 'release_documentation' + options: > + { + "project_id": "merchant-sdk", + "module_id": "sdk", + "documentation_title": "Gini Merchant SDK for Android", + "is_stable_release": "${{ steps.branch.outputs.branch_name == 'main' }}", + "git_tag": "${{ github.ref }}", + "ci": "true", + "git_user": "${{ secrets.RELEASE_GITHUB_USER }}", + "git_password": "${{ secrets.RELEASE_GITHUB_PASSWORD }}" + } + + - name: archive documentation + uses: actions/upload-artifact@v3 + with: + name: merchant-sdk-documentation + path: merchant-sdk/sdk/build/docs diff --git a/.github/workflows/merchant-sdk.release.snapshots.yml b/.github/workflows/merchant-sdk.release.snapshots.yml new file mode 100644 index 0000000000..91dd655165 --- /dev/null +++ b/.github/workflows/merchant-sdk.release.snapshots.yml @@ -0,0 +1,57 @@ +# Disabled until we find a way to avoid manually appending "-SNAPSHOT" to versions after every release. +# Or we find a way to inject the version as a gradle property ONLY for the module we are releasing. + +# name: Release snapshot of Merchant SDK + +# on: +# pull_request: + +# jobs: +# release-snapshot: +# runs-on: ubuntu-latest +# steps: +# - name: checkout +# uses: actions/checkout@v3 + +# - name: setup java +# uses: actions/setup-java@v3 +# with: +# distribution: 'temurin' +# java-version: '17' +# cache: 'gradle' + +# - name: setup ruby +# uses: ruby/setup-ruby@v1 +# with: +# ruby-version: '3.2.0' +# bundler-cache: true + +# - name: "publish to gini's maven snapshots repo at https://repo.gini.net/nexus/content/repositories/snapshots" +# uses: maierj/fastlane-action@v3.0.0 +# with: +# lane: 'publish_to_maven_snapshots_repo' +# options: > +# { +# "repo_url": "https://repo.gini.net/nexus/content/repositories/snapshots", +# "repo_user": "jenkins", +# "repo_password": "${{ secrets.GINI_EXTERNAL_NEXUS_PASSWORD }}", +# "project_id": "merchant-sdk", +# "module_id": "sdk", +# "signing_key_base64": "${{ secrets.MAVEN_CENTRAL_SIGNING_KEY_BASE64 }}", +# "signing_password": "${{ secrets.MAVEN_CENTRAL_SIGNING_PASSWORD }}" +# } + +# - name: "publish to Maven Central snapshots repo at https://oss.sonatype.org/content/repositories/snapshots/" +# uses: maierj/fastlane-action@v3.0.0 +# with: +# lane: 'publish_to_maven_snapshots_repo' +# options: > +# { +# "repo_url": "https://oss.sonatype.org/content/repositories/snapshots/", +# "repo_user": "alpar.gini", +# "repo_password": "${{ secrets.MAVEN_CENTRAL_PASSWORD }}", +# "project_id": "merchant-sdk", +# "module_id": "sdk", +# "signing_key_base64": "${{ secrets.MAVEN_CENTRAL_SIGNING_KEY_BASE64 }}", +# "signing_password": "${{ secrets.MAVEN_CENTRAL_SIGNING_PASSWORD }}" +# } diff --git a/.github/workflows/merchant-sdk.release.yml b/.github/workflows/merchant-sdk.release.yml new file mode 100644 index 0000000000..53ab8f9968 --- /dev/null +++ b/.github/workflows/merchant-sdk.release.yml @@ -0,0 +1,58 @@ +name: Release Merchant SDK + +on: + push: + tags: + - 'merchant-sdk;[0-9]+.[0-9]+.[0-9]+' + - 'merchant-sdk;[0-9]+.[0-9]+.[0-9]+-beta[0-9][0-9]' + +jobs: + check: + uses: ./.github/workflows/merchant-sdk.check.yml + 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 }} + + release: + needs: check + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: setup java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2.0' + bundler-cache: true + + - name: "publish to Maven Central staging repo at https://oss.sonatype.org/service/local/staging/deploy/maven2/" + uses: maierj/fastlane-action@v3.0.0 + with: + lane: 'publish_to_maven_repo' + options: > + { + "repo_url": "https://oss.sonatype.org/service/local/staging/deploy/maven2/", + "repo_user": "${{ secrets.MAVEN_CENTRAL_USER_TOKEN_USERNAME }}", + "repo_password": "${{ secrets.MAVEN_CENTRAL_USER_TOKEN_PASSWORD }}", + "project_id": "merchant-sdk", + "module_id": "sdk", + "build_number": "${{ github.run_number }}", + "git_tag": "${{ github.ref }}", + "signing_key_base64": "${{ secrets.MAVEN_CENTRAL_SIGNING_KEY_BASE64 }}", + "signing_password": "${{ secrets.MAVEN_CENTRAL_SIGNING_PASSWORD }}" + } + + release-documentation: + needs: release + uses: ./.github/workflows/merchant-sdk.docs.release.yml + secrets: + RELEASE_GITHUB_USER: ${{ secrets.RELEASE_GITHUB_USER }} + RELEASE_GITHUB_PASSWORD: ${{ secrets.RELEASE_GITHUB_PASSWORD }} diff --git a/RELEASE-ORDER.md b/RELEASE-ORDER.md index 7c0c761d1f..74a7660857 100644 --- a/RELEASE-ORDER.md +++ b/RELEASE-ORDER.md @@ -20,6 +20,11 @@ Release order for :health-sdk:sdk 4.2.0: 2. :health-api-library:library 4.2.0 3. :health-sdk:sdk 4.2.0 +Release order for :merchant-sdk:sdk 1.0.0: + 1. :core-api-library:library 2.2.0 + 2. :health-api-library:library 4.2.0 + 3. :merchant-sdk:sdk 1.0.0 + Release order for :capture-sdk:default-network 3.11.0: 1. :core-api-library:library 2.2.0 2. :bank-api-library:library 3.2.0 diff --git a/bank-sdk/example-app/README.md b/bank-sdk/example-app/README.md index c5040d2357..cb5563f9ca 100644 --- a/bank-sdk/example-app/README.md +++ b/bank-sdk/example-app/README.md @@ -34,8 +34,8 @@ is used for creating release builds which can be shared with clients while the ` for QA purposes. The difference between `prod` and `qa` is that `qa` allows using custom SSL root certificates for SSL proxies (e.g. Charles Proxy). -Payment Providers for testing Gini Pay Connect and the Health SDK -============================================================== +Payment Providers for testing Gini Pay Connect with the Health SDK or Merchant SDK +================================================================================== Run `bundle exec fastlane install_test_payment_provider_apps` in the repository root to install test payment provider apps on all running emulators and connected devices. You can run `bundle exec fastlane uninstall_test_payment_provider_apps` diff --git a/health-api-library/library/src/main/java/net/gini/android/health/api/GiniHealthAPIBuilder.kt b/health-api-library/library/src/main/java/net/gini/android/health/api/GiniHealthAPIBuilder.kt index 08299e752e..8836def33c 100644 --- a/health-api-library/library/src/main/java/net/gini/android/health/api/GiniHealthAPIBuilder.kt +++ b/health-api-library/library/src/main/java/net/gini/android/health/api/GiniHealthAPIBuilder.kt @@ -28,10 +28,11 @@ class GiniHealthAPIBuilder @JvmOverloads constructor( clientId: String = "", clientSecret: String = "", emailDomain: String = "", - sessionManager: SessionManager? = null + sessionManager: SessionManager? = null, + apiVersion: Int = API_VERSION ) : GiniCoreAPIBuilder(context, clientId, clientSecret, emailDomain, sessionManager) { - private val healthApiType = GiniHealthApiType(4) + private val healthApiType = GiniHealthApiType(apiVersion) override fun getGiniApiType(): GiniApiType { return healthApiType @@ -57,4 +58,8 @@ class GiniHealthAPIBuilder @JvmOverloads constructor( override fun createDocumentRepository(): HealthApiDocumentRepository { return HealthApiDocumentRepository(createDocumentRemoteSource(), getSessionManager(), healthApiType) } -} \ No newline at end of file + + companion object { + const val API_VERSION = 4 + } +} diff --git a/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/paymentprovider/PaymentProviderApp.kt b/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/paymentprovider/PaymentProviderApp.kt index 5c14a2d9db..99a64523d9 100644 --- a/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/paymentprovider/PaymentProviderApp.kt +++ b/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/paymentprovider/PaymentProviderApp.kt @@ -47,7 +47,6 @@ private fun getPaymentProviderAppQueryIntent() = Intent().apply { action = Intent.ACTION_VIEW data = Uri.parse(QueryUri) } - data class PaymentProviderApp( val name: String, val icon: BitmapDrawable?, diff --git a/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/util/GiniHealthAPI.kt b/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/util/GiniHealthAPI.kt index b57b94d086..ce3d98940f 100644 --- a/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/util/GiniHealthAPI.kt +++ b/health-sdk/sdk/src/main/java/net/gini/android/health/sdk/util/GiniHealthAPI.kt @@ -8,18 +8,19 @@ import net.gini.android.health.api.GiniHealthAPIBuilder /** * Minimal configuration for Gini API */ -fun getGiniApi(context: Context, clientId: String, clientSecret: String, emailDomain: String): GiniHealthAPI { +fun getGiniApi(context: Context, clientId: String, clientSecret: String, emailDomain: String, apiVersion: Int = GiniHealthAPIBuilder.API_VERSION): GiniHealthAPI { return GiniHealthAPIBuilder( context, clientId, clientSecret, - emailDomain + emailDomain, + apiVersion = apiVersion ).build() } /** * Minimal configuration for Gini API */ -fun getGiniApi(context: Context, sessionManager: SessionManager): GiniHealthAPI { - return GiniHealthAPIBuilder(context, sessionManager = sessionManager).build() -} \ No newline at end of file +fun getGiniApi(context: Context, sessionManager: SessionManager, apiVersion: Int = GiniHealthAPIBuilder.API_VERSION): GiniHealthAPI { + return GiniHealthAPIBuilder(context, sessionManager = sessionManager, apiVersion = apiVersion).build() +} diff --git a/merchant-sdk/LICENSE.md b/merchant-sdk/LICENSE.md new file mode 100644 index 0000000000..adfb9b05e7 --- /dev/null +++ b/merchant-sdk/LICENSE.md @@ -0,0 +1 @@ +Moved to https://developer.gini.net/gini-mobile-android/merchant-sdk/sdk/html/license.html \ No newline at end of file diff --git a/merchant-sdk/README.md b/merchant-sdk/README.md new file mode 100644 index 0000000000..06b041b6ac --- /dev/null +++ b/merchant-sdk/README.md @@ -0,0 +1,41 @@ +![Gini Merchant SDK for Android](./logo.png) + +Gini Merchant SDK for Android +============================= + +The Gini Merchant SDK for Android provides all the UI and functionality needed to use the Gini Pay Connect payment +method in Android apps. The payment information can be reviewed and paid using any available payment provider app (e.g., +banking app). + +The Gini Merchant API provides a secure channel for sharing payment related information between clients. In addition, it +also provides an information extraction service for analyzing invoices. Specifically, it extracts information such as +the document sender or the payment relevant information (amount to pay, IBAN, etc.). + +Documentation +------------- + +* [Integration Guide](https://developer.gini.net/gini-mobile-android/merchant-sdk/sdk) + +Example Apps +------------ + +### Merchant Example App + +You can see a sample usage of the Gini Merchant SDK in the `:merchant-sdk:example-app` module. + +It requires Gini Merchant API credentials which are injected automatically if you create this file `merchant-sdk/example-app/local.properties` with the following properties: +``` +clientId=******* +clientSecret=******* +``` + +### Bank Example App + +An example bank app is available in the [Gini Bank SDK](https://github.com/gini/gini-mobile-android/tree/main/bank-sdk) called +[`example-app`](https://github.com/gini/gini-mobile-android/tree/main/bank-sdk/example-app). + +License +------- + +The Gini Merchant SDK for Android is available under a commercial license. +See the LICENSE file for more info. diff --git a/merchant-sdk/example-app/.gitignore b/merchant-sdk/example-app/.gitignore new file mode 100644 index 0000000000..6e39412206 --- /dev/null +++ b/merchant-sdk/example-app/.gitignore @@ -0,0 +1 @@ +client.properties \ No newline at end of file diff --git a/merchant-sdk/example-app/README.md b/merchant-sdk/example-app/README.md new file mode 100644 index 0000000000..d0c8f5cd28 --- /dev/null +++ b/merchant-sdk/example-app/README.md @@ -0,0 +1,42 @@ +Gini Merchant SDK Example App +============================= + +This example app provides you with a sample usage of the Gini Merchant SDK. + +Before using the Gini Merchant SDK example app, you need to set your Gini Merchant API client id and secret by creating a +`local.properties` file in this folder containing a `clientId` and a `clientSecret` property. + +ProGuard +======== + +A sample ProGuard configuration file is included in the example app's directory called `proguard-rules.pro`. + +The release build is configured to run ProGuard. You need a keystore with a key to sign it. Create a keystore with a key and provide them in +the `gradle.properties` or as arguments for the build command: + +``` +$ ./gradlew merchant-sdk:example-app:assembleRelease \ + -PreleaseKeystoreFile= \ + -PreleaseKeystorePassword= \ + -PreleaseKeyAlias= \ + -PreleaseKeyPassword= \ + -PclientId= \ + -PclientSecret= +``` + +Flavors +======= + +The example app has three flavors: `dev`, `prod` and `qa`. The `dev` flavor is used by default. The `prod` flavor +is used for creating release builds which can be shared with clients while the `qa` flavor is used for creating release builds +for QA purposes. The difference between `prod` and `qa` is that `qa` allows using custom SSL root certificates for +SSL proxies (e.g. Charles Proxy). + +Payment Providers for testing the Merchant SDK +============================================ + +Payment Providers for testing are provided by the Gini Bank SDK's example app. Run `bundle exec fastlane install_test_payment_provider_apps` in the repository root to install test payment provider apps +on all running emulators and connected devices. You can run `bundle exec fastlane uninstall_test_payment_provider_apps` +to uninstall the test payment provider apps. + +Please view the Gini Bank SDK example app's readme at `bank-sdk/example-app/README.md` for more details. diff --git a/merchant-sdk/example-app/build.gradle.kts b/merchant-sdk/example-app/build.gradle.kts new file mode 100644 index 0000000000..f7ad42a67e --- /dev/null +++ b/merchant-sdk/example-app/build.gradle.kts @@ -0,0 +1,140 @@ +import net.gini.gradle.CreatePropertiesTask +import net.gini.gradle.PropertiesPlugin +import net.gini.gradle.readLocalPropertiesToMap + +plugins { + id("com.android.application") + kotlin("android") + kotlin("kapt") +} + +android { + namespace = "net.gini.android.merchant.sdk.exampleapp" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + // after upgrading to AGP 8, we need this to have the defaultConfig block + buildFeatures { + buildConfig = true + } + defaultConfig { + applicationId = "net.gini.android.merchant.sdk.exampleapp" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk =libs.versions.android.targetSdk.get().toInt() + + versionName = version as String + versionCode = (properties["versionCode"] as? String)?.toInt() ?: 0 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + signingConfigs { + create("release") { + storeFile = file(properties["releaseKeystoreFile"] ?: "") + storePassword = (properties["releaseKeystorePassword"] as? String) ?: "" + keyAlias = (properties["releaseKeyAlias"] as? String) ?: "" + keyPassword = (properties["releaseKeyPassword"] as? String) ?: "" + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + + signingConfig = signingConfigs.getByName("release") + } + } + flavorDimensions += "environment" + productFlavors { + create("prod") { + dimension = "environment" + } + create("dev") { + isDefault = true + dimension = "environment" + } + create("qa") { + dimension = "environment" + } + } + buildFeatures { + viewBinding = true + } + compileOptions { + sourceCompatibility(JavaVersion.VERSION_1_8) + targetCompatibility(JavaVersion.VERSION_1_8) + } + kotlinOptions { + jvmTarget = "1.8" + // Fix for "Inheritance from an interface with '@JvmDefault' members is only allowed with -Xjvm-default option" + // https://issuetracker.google.com/issues/217593040#comment6 + freeCompilerArgs = listOf("-Xjvm-default=all") + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +// after upgrading to AGP 8, we need this, otherwise, gradle will complain to use the same jdk version as your machine (17 which is bundled with Android Studio) +// https://youtrack.jetbrains.com/issue/KT-55947/Unable-to-set-kapt-jvm-target-version +tasks.withType(type = org.jetbrains.kotlin.gradle.internal.KaptGenerateStubsTask::class) { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.material) + implementation(libs.androidx.constraintlayout) + implementation(libs.koin.androidx.scope) + implementation(libs.koin.androidx.viewmodel) + implementation(libs.koin.androidx.fragment) + implementation(libs.insetter) + implementation(libs.datastore.preferences) + implementation(libs.moshi.core) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(project(":merchant-sdk:sdk")) + + kapt(libs.moshi.codegen) + implementation(libs.logback.android.core) + implementation(libs.logback.android.classic) { + // workaround issue #73 + exclude(group = "com.google.android", module = "android") + } + + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.espresso.core) +} + +apply() + +tasks.register("injectClientCredentials") { + val propertiesMap = mutableMapOf() + + doFirst { + propertiesMap.clear() + propertiesMap.putAll(readLocalPropertiesToMap(project, listOf("clientId", "clientSecret"))) + } + + destinations.put( + file("src/main/resources/client.properties"), + propertiesMap + ) +} + +afterEvaluate { + tasks.filter { it.name.startsWith("assemble", ignoreCase = true) }.forEach { + it.dependsOn(tasks.getByName("injectClientCredentials")) + } +} diff --git a/merchant-sdk/example-app/gradle.properties b/merchant-sdk/example-app/gradle.properties new file mode 100644 index 0000000000..8560c28e41 --- /dev/null +++ b/merchant-sdk/example-app/gradle.properties @@ -0,0 +1,8 @@ +version=1.0.0 +versionCode=1 + +# Signing +releaseKeystoreFile=merchant_sdk_example.jks +releaseKeystorePassword=*** +releaseKeyAlias=merchant_sdk_example +releaseKeyPassword=*** diff --git a/merchant-sdk/example-app/merchant_sdk_example.jks b/merchant-sdk/example-app/merchant_sdk_example.jks new file mode 100644 index 0000000000..a66a85e925 Binary files /dev/null and b/merchant-sdk/example-app/merchant_sdk_example.jks differ diff --git a/merchant-sdk/example-app/proguard-rules.pro b/merchant-sdk/example-app/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/merchant-sdk/example-app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/merchant-sdk/example-app/src/androidTest/java/net/gini/pay/app/ExampleInstrumentedTest.kt b/merchant-sdk/example-app/src/androidTest/java/net/gini/pay/app/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..28e44466ae --- /dev/null +++ b/merchant-sdk/example-app/src/androidTest/java/net/gini/pay/app/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package net.gini.pay.app + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("net.gini.pay.app", appContext.packageName) + } +} \ No newline at end of file diff --git a/merchant-sdk/example-app/src/dev/AndroidManifest.xml b/merchant-sdk/example-app/src/dev/AndroidManifest.xml new file mode 100644 index 0000000000..e76c158e0e --- /dev/null +++ b/merchant-sdk/example-app/src/dev/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/merchant-sdk/example-app/src/dev/res/xml/network_security_config.xml b/merchant-sdk/example-app/src/dev/res/xml/network_security_config.xml new file mode 100644 index 0000000000..f81d491896 --- /dev/null +++ b/merchant-sdk/example-app/src/dev/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/AndroidManifest.xml b/merchant-sdk/example-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..188c336c70 --- /dev/null +++ b/merchant-sdk/example-app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/ic_launcher-playstore.png b/merchant-sdk/example-app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..16e6d83730 Binary files /dev/null and b/merchant-sdk/example-app/src/main/ic_launcher-playstore.png differ diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/ExampleApplication.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/ExampleApplication.kt new file mode 100644 index 0000000000..21cc501932 --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/ExampleApplication.kt @@ -0,0 +1,20 @@ +package net.gini.android.merchant.sdk.exampleapp + +import android.app.Application +import net.gini.android.merchant.sdk.exampleapp.di.giniModule +import net.gini.android.merchant.sdk.exampleapp.di.viewModelModule +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +class ExampleApplication : Application() { + + override fun onCreate() { + super.onCreate() + + startKoin { + androidContext(this@ExampleApplication) + fileProperties("/client.properties") + modules(giniModule, viewModelModule) + } + } +} \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/MainActivity.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/MainActivity.kt new file mode 100644 index 0000000000..ebe945fc9e --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/MainActivity.kt @@ -0,0 +1,70 @@ +package net.gini.android.merchant.sdk.exampleapp + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.android.LogcatAppender +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import net.gini.android.merchant.sdk.exampleapp.configuration.ConfigurationFragment +import net.gini.android.merchant.sdk.exampleapp.databinding.ActivityMainBinding +import net.gini.android.merchant.sdk.exampleapp.orders.ui.OrdersActivity +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.slf4j.LoggerFactory + +class MainActivity : AppCompatActivity() { + + private val viewModel: MainViewModel by viewModel() + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.invoicesScreen.setOnClickListener { + startActivity(Intent(this, OrdersActivity::class.java).apply { + viewModel.getFlowConfiguration()?.let { + putExtra(FLOW_CONFIGURATION, it) + } + }) + } + + with(binding.giniMerchantVersion) { + text = "${getString(R.string.gini_merchant_version)} ${net.gini.android.merchant.sdk.BuildConfig.VERSION_NAME}" + setOnClickListener { + openConfigurationScreen() + } + } + + configureLogging() + } + + private fun openConfigurationScreen() { + supportFragmentManager.beginTransaction() + .add(binding.configurationContainer.id, ConfigurationFragment.newInstance(), ConfigurationFragment::class.java.name) + .addToBackStack(ConfigurationFragment::class.java.name) + .commit() + } + + private fun configureLogging() { + val lc = LoggerFactory.getILoggerFactory() as LoggerContext + lc.reset() + val layoutEncoder = PatternLayoutEncoder() + layoutEncoder.context = lc + layoutEncoder.pattern = "%-5level %file:%line [%thread] - %msg%n" + layoutEncoder.start() + val logcatAppender = LogcatAppender() + logcatAppender.context = lc + logcatAppender.encoder = layoutEncoder + logcatAppender.start() + val root = LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME) as Logger + root.addAppender(logcatAppender) + } + + companion object { + private val LOG = LoggerFactory.getLogger(MainActivity::class.java) + val FLOW_CONFIGURATION = "flow_configuration" + } +} diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/MainViewModel.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/MainViewModel.kt new file mode 100644 index 0000000000..b447d43a50 --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/MainViewModel.kt @@ -0,0 +1,15 @@ +package net.gini.android.merchant.sdk.exampleapp + +import androidx.lifecycle.ViewModel +import net.gini.android.merchant.sdk.integratedFlow.PaymentFlowConfiguration + +class MainViewModel: ViewModel() { + + private var flowConfiguration: PaymentFlowConfiguration? = null + + fun saveConfiguration(flowConfig: PaymentFlowConfiguration) { + flowConfiguration = flowConfig + } + + fun getFlowConfiguration() = flowConfiguration +} diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/configuration/ConfigurationFragment.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/configuration/ConfigurationFragment.kt new file mode 100644 index 0000000000..f3b52748b4 --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/configuration/ConfigurationFragment.kt @@ -0,0 +1,49 @@ +package net.gini.android.merchant.sdk.exampleapp.configuration + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import net.gini.android.merchant.sdk.exampleapp.MainViewModel +import net.gini.android.merchant.sdk.exampleapp.databinding.FragmentConfigurationBinding +import net.gini.android.merchant.sdk.integratedFlow.PaymentFlowConfiguration + + +class ConfigurationFragment: Fragment() { + + private lateinit var binding: FragmentConfigurationBinding + private val viewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentConfigurationBinding.inflate(layoutInflater) + with(binding) { + setupSwitchListeners() + } + return binding.root + } + + private fun FragmentConfigurationBinding.setupSwitchListeners() { + gmsShowReviewFragment.setOnCheckedChangeListener { _, _ -> + saveConfiguration() + } + } + + private fun saveConfiguration() { + viewModel.saveConfiguration( + PaymentFlowConfiguration( + shouldShowReviewFragment = binding.gmsShowReviewFragment.isChecked, + shouldHandleErrorsInternally = true, + ) + ) + } + + companion object { + fun newInstance() = ConfigurationFragment() + } +} \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/di/GiniModule.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/di/GiniModule.kt new file mode 100644 index 0000000000..08b4f4bac7 --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/di/GiniModule.kt @@ -0,0 +1,8 @@ +package net.gini.android.merchant.sdk.exampleapp.di + +import net.gini.android.merchant.sdk.GiniMerchant +import org.koin.dsl.module + +val giniModule = module { + single { GiniMerchant(get(), getProperty("clientId"), getProperty("clientSecret"), "example.com") } +} diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/di/ViewModel.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/di/ViewModel.kt new file mode 100644 index 0000000000..3295d8755f --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/di/ViewModel.kt @@ -0,0 +1,17 @@ +package net.gini.android.merchant.sdk.exampleapp.di + +import net.gini.android.merchant.sdk.exampleapp.MainViewModel +import net.gini.android.merchant.sdk.exampleapp.orders.data.HardcodedOrdersLocalDataSource +import net.gini.android.merchant.sdk.exampleapp.orders.data.OrdersRepository +import net.gini.android.merchant.sdk.exampleapp.orders.ui.OrderDetailsViewModel +import net.gini.android.merchant.sdk.exampleapp.orders.ui.OrdersViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val viewModelModule = module { + viewModel { MainViewModel() } + viewModel { OrdersViewModel(get(), get()) } + viewModel { OrderDetailsViewModel() } + factory { OrdersRepository(get()) } + factory { HardcodedOrdersLocalDataSource() } +} \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/data/HardcodedOrdersLocalDataSource.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/data/HardcodedOrdersLocalDataSource.kt new file mode 100644 index 0000000000..a2b878142f --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/data/HardcodedOrdersLocalDataSource.kt @@ -0,0 +1,41 @@ +package net.gini.android.merchant.sdk.exampleapp.orders.data + +import net.gini.android.merchant.sdk.exampleapp.orders.data.model.Order + +class HardcodedOrdersLocalDataSource { + + fun getOrders(): List { + return listOf( + Order( + iban = "DE75201207003100124444", + recipient = "OTTO GMBH & CO KG", + amount = "709.97:EUR", + purpose = "RF7411164022" + ), + Order( + iban = "DE14200800000816170700", + recipient = "Tchibo GmbH", + amount = "54.97:EUR", + purpose = "10020302020" + ), + Order( + iban = "DE86210700200123010101", + recipient = "Zalando SE", + amount = "126.62:EUR", + purpose = "938929192" + ), + Order( + iban = "DE68201207003100755555", + recipient = "bonprix Handelsgesellschaft mbH", + amount = "114.88:EUR", + purpose = "020329984871123" + ), + Order( + iban = "DE13760700120500154000", + recipient = "Klarna", + amount = "80.13:EUR", + purpose = "00425818528311423079" + ) + ) + } +} diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/data/OrdersRepository.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/data/OrdersRepository.kt new file mode 100644 index 0000000000..390ba41c9e --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/data/OrdersRepository.kt @@ -0,0 +1,15 @@ +package net.gini.android.merchant.sdk.exampleapp.orders.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import net.gini.android.merchant.sdk.exampleapp.orders.data.model.Order + +class OrdersRepository( + hardcodedOrdersLocalDataSource: HardcodedOrdersLocalDataSource +) { + + val ordersFlow: Flow> = flow { + emit(hardcodedOrdersLocalDataSource.getOrders()) + } + +} diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/data/model/Order.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/data/model/Order.kt new file mode 100644 index 0000000000..fb1ae6974f --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/data/model/Order.kt @@ -0,0 +1,3 @@ +package net.gini.android.merchant.sdk.exampleapp.orders.data.model + +data class Order(val iban: String, val recipient: String, val amount: String, val purpose: String) \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrderDetailsFragment.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrderDetailsFragment.kt new file mode 100644 index 0000000000..9ebc6fed3c --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrderDetailsFragment.kt @@ -0,0 +1,106 @@ +package net.gini.android.merchant.sdk.exampleapp.orders.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.textfield.TextInputEditText +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import net.gini.android.merchant.sdk.exampleapp.databinding.FragmentOrderDetailsBinding +import net.gini.android.merchant.sdk.exampleapp.orders.data.model.Order +import net.gini.android.merchant.sdk.util.setIntervalClickListener + +class OrderDetailsFragment : Fragment() { + + private lateinit var binding: FragmentOrderDetailsBinding + private val ordersViewModel: OrdersViewModel by activityViewModels() + private val orderDetailsViewModel: OrderDetailsViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + super.onCreateView(inflater, container, savedInstanceState) + binding = FragmentOrderDetailsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { + ordersViewModel.selectedOrderItem.collectLatest { orderItem -> + if (orderItem == null) { + return@collectLatest + } + showOrder(orderItem.order) + orderDetailsViewModel.setOrder(orderItem.order) + } + } + launch { + orderDetailsViewModel.orderFlow.collectLatest { order -> + showOrder(order) + } + } + launch { + ordersViewModel.finishPaymentFlow.collect { + if (it == true) { + requireActivity().supportFragmentManager.popBackStack() + ordersViewModel.resetFinishPaymentFlow() + } + } + } + } + } + setupInputListeners() + } + + private fun showOrder(order: Order) { + with(binding) { + recipient.setTextIfDifferent(order.recipient) + iban.setTextIfDifferent(order.iban) + amount.setTextIfDifferent(order.amount) + purpose.setTextIfDifferent(order.purpose) + payNowBtn.setIntervalClickListener { + ordersViewModel.startPaymentFlow(orderDetailsViewModel.getOrder()) + } + } + } + + private fun setupInputListeners() { + with(binding) { + recipient.addTextChangedListener { text -> + orderDetailsViewModel.updateRecipient(text.toString()) + } + iban.addTextChangedListener { text -> + orderDetailsViewModel.updateIBAN(text.toString()) + } + amount.addTextChangedListener { text -> + orderDetailsViewModel.updateAmount(text.toString()) + } + purpose.addTextChangedListener { text -> + orderDetailsViewModel.updatePurpose(text.toString()) + } + } + } + + companion object { + fun newInstance() = OrderDetailsFragment() + } +} + +private fun TextInputEditText.setTextIfDifferent(text: String) { + if (this.text.toString() != text) { + this.setText(text) + } +} \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrderDetailsViewModel.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrderDetailsViewModel.kt new file mode 100644 index 0000000000..a2cca28d55 --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrderDetailsViewModel.kt @@ -0,0 +1,41 @@ +package net.gini.android.merchant.sdk.exampleapp.orders.ui + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import net.gini.android.merchant.sdk.exampleapp.orders.data.model.Order +import kotlin.time.Duration.Companion.milliseconds + +class OrderDetailsViewModel : ViewModel() { + + private val _orderFlow = MutableStateFlow(Order("", "", "", "")) + + @OptIn(FlowPreview::class) + val orderFlow = _orderFlow.asStateFlow().debounce(300.milliseconds) + + fun setOrder(order: Order) { + _orderFlow.value = order + } + + fun getOrder(): Order { + return _orderFlow.value + } + + fun updateRecipient(recipient: String) { + _orderFlow.value = _orderFlow.value.copy(recipient = recipient) + } + + fun updateIBAN(iban: String) { + _orderFlow.value = _orderFlow.value.copy(iban = iban) + } + + fun updateAmount(amount: String) { + _orderFlow.value = _orderFlow.value.copy(amount = amount) + } + + fun updatePurpose(purpose: String) { + _orderFlow.value = _orderFlow.value.copy(purpose = purpose) + } +} \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrdersActivity.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrdersActivity.kt new file mode 100644 index 0000000000..22de1114a9 --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrdersActivity.kt @@ -0,0 +1,209 @@ +package net.gini.android.merchant.sdk.exampleapp.orders.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.IntentCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.launch +import net.gini.android.merchant.sdk.GiniMerchant +import net.gini.android.merchant.sdk.exampleapp.MainActivity +import net.gini.android.merchant.sdk.exampleapp.R +import net.gini.android.merchant.sdk.exampleapp.databinding.ActivityOrdersBinding +import net.gini.android.merchant.sdk.exampleapp.orders.ui.model.OrderItem +import net.gini.android.merchant.sdk.integratedFlow.PaymentFlowConfiguration +import net.gini.android.merchant.sdk.integratedFlow.PaymentFragment +import net.gini.android.merchant.sdk.util.DisplayedScreen +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.slf4j.LoggerFactory + +class OrdersActivity : AppCompatActivity() { + + private val viewModel: OrdersViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityOrdersBinding.inflate(layoutInflater) + setContentView(binding.root) + setActivityTitle() + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.ordersFlow.collect { orders -> + (binding.ordersList.adapter as OrdersAdapter).apply { + dataSet = orders + notifyDataSetChanged() + } + binding.noOrdersLabel.visibility = + if (orders.isEmpty()) View.VISIBLE else View.GONE + } + } + launch { + viewModel.startIntegratedPaymentFlow.collect { containerFragment -> + startIntegratedPaymentFlow(containerFragment) + } + } + launch { + viewModel.giniMerchant.eventsFlow.collect { event -> + when (event) { + is GiniMerchant.MerchantSDKEvents.OnScreenDisplayed -> { + when (event.displayedScreen) { + DisplayedScreen.MoreInformationFragment -> setActivityTitle(net.gini.android.merchant.sdk.R.string.gms_more_information_fragment_title) + DisplayedScreen.ReviewFragment -> setActivityTitle(R.string.title_fragment_order_details) + else -> { setActivityTitle(R.string.title_activity_orders) } + } + } + is GiniMerchant.MerchantSDKEvents.OnErrorOccurred -> { + AlertDialog.Builder(this@OrdersActivity) + .setTitle(R.string.error_message) + .setMessage(event.throwable.message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + else -> {} + } + } + } + launch { + viewModel.errorsFlow.collect { + Toast.makeText(this@OrdersActivity, it, Toast.LENGTH_LONG).show() + } + } + } + } + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + viewModel.loadPaymentProviderApps() + viewModel.startObservingPaymentFlow() + + IntentCompat.getParcelableExtra(intent, MainActivity.FLOW_CONFIGURATION, PaymentFlowConfiguration::class.java)?.let { + viewModel.setIntegratedFlowConfiguration(it) + } + + binding.ordersList.layoutManager = LinearLayoutManager(this) + binding.ordersList.adapter = OrdersAdapter(emptyList()) { orderItem -> + viewModel.setSelectedOrderItem(orderItem) + showOrderDetailsFragment() + } + binding.ordersList.addItemDecoration(DividerItemDecoration(this, LinearLayout.VERTICAL)) + + supportFragmentManager.addOnBackStackChangedListener { + setActivityTitle() + invalidateOptionsMenu() + } + } + + private fun setActivityTitle(@StringRes screenTitle: Int? = null) { + if (supportFragmentManager.backStackEntryCount == 0) { + title = getString(R.string.title_activity_orders) + } else if (supportFragmentManager.fragments.last() is OrderDetailsFragment) { + title = getString(R.string.title_fragment_order_details) + } else if (screenTitle != null){ + title = getString(screenTitle) + } + } + + private fun showOrderDetailsFragment() { + OrderDetailsFragment.newInstance().apply { + add() + } + } + + private fun startIntegratedPaymentFlow(containerFragment: PaymentFragment) { + containerFragment.apply { + add() + } + } + + private fun Fragment.add() { + supportFragmentManager.beginTransaction() + .add(R.id.fragment_container, this, this::class.java.simpleName) + .addToBackStack(this::class.java.simpleName) + .commit() + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + if (supportFragmentManager.backStackEntryCount == 0) { + menuInflater.inflate(R.menu.orders_menu, menu) + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.custom_order -> { + viewModel.setSelectedOrderItem(null) + showOrderDetailsFragment() + true + } + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + true + } + + else -> super.onOptionsItemSelected(item) + } + } + + companion object { + private val LOG = LoggerFactory.getLogger(OrdersActivity::class.java) + } +} + +class OrdersAdapter( + var dataSet: List, + private val showOrderDetails: (OrderItem) -> Unit, +) : + RecyclerView.Adapter() { + + class ViewHolder(view: View) : + RecyclerView.ViewHolder(view) { + val recipient: TextView + val purpose: TextView + val amount: TextView + + init { + recipient = view.findViewById(R.id.recipient) + purpose = view.findViewById(R.id.purpose) + amount = view.findViewById(R.id.amount) + } + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_order, viewGroup, false) + return ViewHolder(view).also { vh -> + vh.itemView.setOnClickListener { + showOrderDetails(dataSet[vh.adapterPosition]) + } + } + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val orderItem = dataSet[position] + + viewHolder.recipient.text = orderItem.recipient ?: "" + viewHolder.purpose.text = orderItem.purpose ?: "" + viewHolder.amount.text = orderItem.amount ?: "" + + } + + override fun getItemCount() = dataSet.size +} diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrdersViewModel.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrdersViewModel.kt new file mode 100644 index 0000000000..58cfa50331 --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/OrdersViewModel.kt @@ -0,0 +1,92 @@ +package net.gini.android.merchant.sdk.exampleapp.orders.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import net.gini.android.merchant.sdk.GiniMerchant +import net.gini.android.merchant.sdk.exampleapp.orders.data.OrdersRepository +import net.gini.android.merchant.sdk.exampleapp.orders.data.model.Order +import net.gini.android.merchant.sdk.exampleapp.orders.ui.model.OrderItem +import net.gini.android.merchant.sdk.integratedFlow.PaymentFlowConfiguration +import net.gini.android.merchant.sdk.integratedFlow.PaymentFragment +import org.slf4j.LoggerFactory + +class OrdersViewModel( + private val ordersRepository: OrdersRepository, + val giniMerchant: GiniMerchant +) : ViewModel() { + + val ordersFlow = ordersRepository.ordersFlow.map { orders -> + orders.map { order -> OrderItem.fromOrder(order) } + } + + private val _selectedOrderItem: MutableStateFlow = MutableStateFlow(null) + val selectedOrderItem: StateFlow = _selectedOrderItem + + private val _startIntegratedPaymentFlow = MutableSharedFlow( + extraBufferCapacity = 1 + ) + val startIntegratedPaymentFlow = _startIntegratedPaymentFlow + + private val _errorsFlow = MutableSharedFlow(extraBufferCapacity = 1) + val errorsFlow: SharedFlow = _errorsFlow + + private var paymentFlowConfiguration: PaymentFlowConfiguration? = null + + private var _finishPaymentFlow = MutableStateFlow(null) + val finishPaymentFlow: StateFlow = _finishPaymentFlow + + fun startObservingPaymentFlow() = viewModelScope.launch { + giniMerchant.eventsFlow.collect { event -> + when (event) { + is GiniMerchant.MerchantSDKEvents.OnFinishedWithPaymentRequestCreated, + is GiniMerchant.MerchantSDKEvents.OnFinishedWithCancellation -> { + _finishPaymentFlow.tryEmit(true) + } + else -> {} + } + } + } + + fun loadPaymentProviderApps() { + viewModelScope.launch { + giniMerchant.loadPaymentProviderApps() + } + } + + fun setSelectedOrderItem(orderItem: OrderItem?) = viewModelScope.launch { + _selectedOrderItem.emit(orderItem) + } + + fun startPaymentFlow(order: Order) { + try { + _startIntegratedPaymentFlow.tryEmit(giniMerchant.createFragment( + recipient = order.recipient, + iban = order.iban, + purpose = order.purpose, + amount = order.amount.replace(":[A-Z]{3}$".toRegex(), ""), + flowConfiguration = paymentFlowConfiguration)) + } catch (e: IllegalStateException) { + LOG.error(e.message) + _errorsFlow.tryEmit(e.message ?: "") + } + } + + fun setIntegratedFlowConfiguration(flowConfiguration: PaymentFlowConfiguration) { + this.paymentFlowConfiguration = flowConfiguration + } + + fun resetFinishPaymentFlow() { + _finishPaymentFlow.tryEmit(null) + } + + companion object { + private val LOG = LoggerFactory.getLogger(OrdersViewModel::class.java) + + } +} \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/model/OrderItem.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/model/OrderItem.kt new file mode 100644 index 0000000000..0427cf8f31 --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/orders/ui/model/OrderItem.kt @@ -0,0 +1,79 @@ +package net.gini.android.merchant.sdk.exampleapp.orders.ui.model + +import net.gini.android.merchant.sdk.exampleapp.orders.data.model.Order +import java.math.BigDecimal +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.text.NumberFormat +import java.text.ParseException +import java.util.Currency +import java.util.Locale + +private val PRICE_STRING_REGEX = "^-?[0-9]+([.,])[0-9]+\$".toRegex() + +data class OrderItem( + val order: Order, + val recipient: String, + val amount: String, + val purpose: String +) { + + companion object { + fun fromOrder(order: Order): OrderItem { + return OrderItem( + order = order, + recipient = order.recipient, + amount = parseAmount(order.amount), + purpose = order.purpose + ) + } + + private fun parseAmount(amount: String): String = amount.split(":").let { substrings -> + if (substrings.size != 2) { + throw java.lang.NumberFormatException( + "Invalid price format. Expected :, but got: $amount" + ) + } + val price = parsePrice(substrings[0]) + val currency = Currency.getInstance(substrings[1]) + + val numberFormat = NumberFormat.getCurrencyInstance() + numberFormat.maximumFractionDigits = 2 + numberFormat.currency = currency + + return numberFormat.format(price) + } + + private fun parsePrice(price: String): BigDecimal = + if (price matches PRICE_STRING_REGEX) { + when { + price.contains(".") -> { + parsePriceWithLocale(price, Locale.ENGLISH) + } + + price.contains(",") -> { + parsePriceWithLocale(price, Locale.GERMAN) + } + + else -> { + throw NumberFormatException("Unknown number format locale") + } + } + } else { + throw NumberFormatException("Invalid number format") + } + + private fun parsePriceWithLocale(price: String, locale: Locale) = DecimalFormat( + "0.00", + DecimalFormatSymbols.getInstance(locale) + ) + .apply { isParseBigDecimal = true } + .run { + try { + parse(price) as BigDecimal + } catch (e: ParseException) { + throw NumberFormatException(e.message) + } + } + } +} \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/util/Extensions.kt b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/util/Extensions.kt new file mode 100644 index 0000000000..91b041fe48 --- /dev/null +++ b/merchant-sdk/example-app/src/main/java/net/gini/android/merchant/sdk/exampleapp/util/Extensions.kt @@ -0,0 +1,14 @@ +package net.gini.android.merchant.sdk.exampleapp.util + +import java.io.ByteArrayOutputStream +import java.io.InputStream + +fun InputStream.getBytes(): ByteArray { + val byteBuffer = ByteArrayOutputStream() + val buffer = ByteArray(1024) + var len: Int + while (this.read(buffer).also { len = it } != -1) { + byteBuffer.write(buffer, 0, len) + } + return byteBuffer.toByteArray() +} \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/merchant-sdk/example-app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2b068d1146 --- /dev/null +++ b/merchant-sdk/example-app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/res/drawable/dot_selected.xml b/merchant-sdk/example-app/src/main/res/drawable/dot_selected.xml new file mode 100644 index 0000000000..0c6887a6c8 --- /dev/null +++ b/merchant-sdk/example-app/src/main/res/drawable/dot_selected.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/res/drawable/dot_unselected.xml b/merchant-sdk/example-app/src/main/res/drawable/dot_unselected.xml new file mode 100644 index 0000000000..d8f74d490e --- /dev/null +++ b/merchant-sdk/example-app/src/main/res/drawable/dot_unselected.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/res/drawable/gini_logo.xml b/merchant-sdk/example-app/src/main/res/drawable/gini_logo.xml new file mode 100644 index 0000000000..6629120b64 --- /dev/null +++ b/merchant-sdk/example-app/src/main/res/drawable/gini_logo.xml @@ -0,0 +1,14 @@ + + + + diff --git a/merchant-sdk/example-app/src/main/res/drawable/ic_close.xml b/merchant-sdk/example-app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000000..9f1eb74239 --- /dev/null +++ b/merchant-sdk/example-app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/merchant-sdk/example-app/src/main/res/drawable/ic_launcher_background.xml b/merchant-sdk/example-app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..ca3826a46c --- /dev/null +++ b/merchant-sdk/example-app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/merchant-sdk/example-app/src/main/res/drawable/tab_pager_selector.xml b/merchant-sdk/example-app/src/main/res/drawable/tab_pager_selector.xml new file mode 100644 index 0000000000..157d397828 --- /dev/null +++ b/merchant-sdk/example-app/src/main/res/drawable/tab_pager_selector.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/merchant-sdk/example-app/src/main/res/layout/activity_main.xml b/merchant-sdk/example-app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..989f0e438e --- /dev/null +++ b/merchant-sdk/example-app/src/main/res/layout/activity_main.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + +