Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.re
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-test-junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-junit" }
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
androidx-test-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso" }
androidx-test-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espresso" }
androidx-test-espresso-idlingresource = { module = "androidx.test.espresso:espresso-idling-resource", version.ref = "espresso" }
androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" }
Expand Down
24 changes: 24 additions & 0 deletions health-sdk/example-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,34 @@ dependencies {

androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.test.espresso.contrib)
androidTestImplementation(libs.androidx.test.uiautomator)
}

apply<PropertiesPlugin>()

/**
* Installs the bank-sdk example app (devPaymentProvider3Debug variant) on the connected
* device/emulator before any connectedAndroidTest task runs.
*
* The bank app package (net.gini.android.bank.insurance.mock) must be present on the device
* so the Health SDK can resolve the payment intent and launch the Bank app during UI tests.
*
* Run manually: ./gradlew :health-sdk:example-app:installBankApp
* Runs automatically before: connectedDevDebugAndroidTest (and all other connected test variants)
*/
val installBankApp by tasks.registering {
group = "verification"
description = "Installs bank-sdk example app (devPaymentProvider3Debug) before UI tests"
dependsOn(":bank-sdk:example-app:installDevPaymentProvider3Debug")
}

tasks.configureEach {
if (name.startsWith("connected") && name.endsWith("AndroidTest")) {
dependsOn(installBankApp)
}
}

tasks.register<CreatePropertiesTask>("injectClientCredentials") {
val propertiesMap = mutableMapOf<String, String>()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package net.gini.android.health.sdk.exampleapp.test.pages

import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import net.gini.android.health.sdk.exampleapp.test.testdata.TestData
import org.junit.Assert.assertEquals

/**
* Page Object Model for the bank-sdk example app payment screen.
*
* Hybrid Espresso + UiAutomator approach:
* 1. Espresso drives the Health app up to and including tapping "Pay"
* 2. [switchToBankApp] switches context via launchIntentForPackage
* (bank app is pre-installed by Gradle before the tests run)
* 3. UiAutomator verifies Bank app fields and taps Pay
* 4. [tapPayAndReturnToBusiness] waits for the deep-link return to Health app
* 5. Espresso regains control of the Health app
*/
class BankScreen {

private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val device: UiDevice = UiDevice.getInstance(instrumentation)

companion object {
private const val LAUNCH_TIMEOUT_MS = 5_000L
private const val FIELD_TIMEOUT_MS = 3_000L
}

/**
* Switches context from the Health app to the Bank app.
*
* The Health SDK already fires the payment intent when "Pay" is tapped,
* so the Bank app may already be in the foreground. If not, we explicitly
* launch it via [android.content.pm.PackageManager.getLaunchIntentForPackage].
*/
fun switchToBankApp(): BankScreen {

val bankAppVisible = device.wait(
Until.hasObject(By.pkg(TestData.AppPackages.BANK_SDK_EXAMPLE_APP).depth(0)),
LAUNCH_TIMEOUT_MS
)

if (!bankAppVisible) {
// Fallback: explicitly launch the bank app
val launchIntent = instrumentation.targetContext.packageManager
.getLaunchIntentForPackage(TestData.AppPackages.BANK_SDK_EXAMPLE_APP)
?.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) }

requireNotNull(launchIntent) {
"Bank app not installed: ${TestData.AppPackages.BANK_SDK_EXAMPLE_APP}"
}

instrumentation.targetContext.startActivity(launchIntent)

device.wait(
Until.hasObject(By.pkg(TestData.AppPackages.BANK_SDK_EXAMPLE_APP).depth(0)),
LAUNCH_TIMEOUT_MS
)
Comment on lines +58 to +61
}

return this
}

/**
* Uses UiAutomator to verify all 4 extracted payment fields in the Bank app.
* Must be called after [switchToBankApp].
*/
fun verifyPaymentDetails(
expectedRecipient: String,
expectedIban: String,
expectedAmount: String,
expectedPurpose: String
): BankScreen {
// Verify Recipient
val recipientField = device.wait(
Until.findObject(By.res(TestData.AppPackages.BANK_SDK_EXAMPLE_APP, "recipient")),
FIELD_TIMEOUT_MS
)
val actualRecipient = recipientField?.text ?: ""
println("✅ Bank App Recipient: $actualRecipient")
assertEquals("Recipient mismatch in bank app", expectedRecipient, actualRecipient)

// Verify IBAN
val actualIban = device
.findObject(By.res(TestData.AppPackages.BANK_SDK_EXAMPLE_APP, "iban"))
?.text ?: ""
println("✅ Bank App IBAN: $actualIban")
assertEquals("IBAN mismatch in bank app", expectedIban, actualIban)

// Verify Amount
val actualAmount = device
.findObject(By.res(TestData.AppPackages.BANK_SDK_EXAMPLE_APP, "amount"))
?.text ?: ""
println("✅ Bank App Amount: $actualAmount")
assertEquals("Amount mismatch in bank app", expectedAmount, actualAmount)

// Verify Purpose
val actualPurpose = device
.findObject(By.res(TestData.AppPackages.BANK_SDK_EXAMPLE_APP, "purpose"))
?.text ?: ""
Comment on lines +87 to +103
println("✅ Bank App Purpose: $actualPurpose")
if (expectedPurpose.isNotEmpty()) {
assertEquals("Purpose mismatch in bank app", expectedPurpose, actualPurpose)
}

return this
}

/**
* Taps the Pay (resolve_payment) button in the Bank app,
* waits for the "Return to Business" button to confirm payment resolved,
* taps it to trigger the deep-link back to the Health app,
* then waits for the Health app to return to the foreground.
* After this call, Espresso regains control of the Health app.
*/
fun tapPayAndReturnToBusiness(): BankScreen {
// Tap resolve_payment (UiAutomator — cross-app)
device.findObject(
By.res(TestData.AppPackages.BANK_SDK_EXAMPLE_APP, "resolve_payment")
)?.click()

// Wait for "Return to Business" button — confirms payment was processed
device.wait(
Until.findObject(
By.res(TestData.AppPackages.BANK_SDK_EXAMPLE_APP, "return_to_payment_initiator_app")
),
FIELD_TIMEOUT_MS
)

// Tap it — triggers deep-link back to Health app
device.findObject(
By.res(TestData.AppPackages.BANK_SDK_EXAMPLE_APP, "return_to_payment_initiator_app")
)?.click()

// Wait for the Health app to return to the foreground — Espresso takes over from here
device.wait(
Until.hasObject(By.pkg(TestData.AppPackages.HEALTH_SDK_EXAMPLE_APP).depth(0)),
LAUNCH_TIMEOUT_MS
)
Comment on lines +121 to +142

return this
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package net.gini.android.health.sdk.exampleapp.test.pages

import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
import androidx.test.espresso.PerformException
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.hasMinimumChildCount
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import net.gini.android.health.sdk.exampleapp.R
import org.hamcrest.Matcher

/**
* Page Object Model for InvoicesActivity.
* Contains all UI interactions and assertions for the invoices list screen.
*/
class InvoicesScreen {

/**
* Opens the overflow menu, taps "Upload Invoices" and waits for
* the invoices list to fully populate.
*/
fun loadInvoices(): InvoicesScreen {
// Open the overflow (ellipse) menu in the toolbar
openActionBarOverflowOrOptionsMenu(
InstrumentationRegistry.getInstrumentation().targetContext
)

// Tap "Upload Invoices" from the overflow menu
onView(withText("Upload Invoices"))
.perform(click())

// Wait for invoices to be uploaded and the list to populate
Thread.sleep(10000)

// Verify the RecyclerView is displayed with at least one invoice item
onView(withId(R.id.invoices_list))
.check(matches(isDisplayed()))
.check(matches(hasMinimumChildCount(1)))

return this
}

/**
* Scrolls through the invoices list to find the item matching [recipientName]
* and taps its pay button.
*/
fun tapInvoiceByRecipient(recipientName: String): InvoicesScreen {
onView(withId(R.id.invoices_list)).perform(object : ViewAction {
override fun getConstraints(): Matcher<View> = isDisplayed()
override fun getDescription() = "scroll to and click pay_invoice_button for recipient: $recipientName"
override fun perform(uiController: UiController, view: View) {
val recyclerView = view as RecyclerView
val adapter = recyclerView.adapter
?: throw PerformException.Builder()
.withActionDescription(description)
.withViewDescription("RecyclerView has no adapter")
.build()

// Find the adapter position of the item matching recipientName
var targetPosition = -1
for (pos in 0 until adapter.itemCount) {
recyclerView.post {
(recyclerView.layoutManager as? LinearLayoutManager)
?.scrollToPositionWithOffset(pos, 0)
}
uiController.loopMainThreadUntilIdle()
Thread.sleep(100)
uiController.loopMainThreadUntilIdle()
Comment on lines +60 to +77

val vh = recyclerView.findViewHolderForAdapterPosition(pos) ?: continue
val recipientView = vh.itemView
.findViewById<android.widget.TextView>(R.id.recipient)
if (recipientView?.text?.toString() == recipientName) {
targetPosition = pos
break
}
}

if (targetPosition == -1) {
throw PerformException.Builder()
.withActionDescription(description)
.withViewDescription(
"No item with recipient '$recipientName' " +
"found in adapter (${adapter.itemCount} items)"
)
.build()
}

// Scroll to the found position and wait for it to settle
recyclerView.post {
(recyclerView.layoutManager as? LinearLayoutManager)
?.scrollToPositionWithOffset(targetPosition, 0)
}
uiController.loopMainThreadUntilIdle()
Thread.sleep(300)
uiController.loopMainThreadUntilIdle()

// Tap the pay button inside the matched item
val targetVh = recyclerView.findViewHolderForAdapterPosition(targetPosition)
?: throw PerformException.Builder()
.withActionDescription(description)
.withViewDescription(
"ViewHolder at position $targetPosition not found after scroll"
)
.build()

val payBtn = targetVh.itemView.findViewById<View>(R.id.pay_invoice_button)
payBtn.callOnClick()
uiController.loopMainThreadUntilIdle()
Thread.sleep(500)
uiController.loopMainThreadUntilIdle()
}
})
return this
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package net.gini.android.health.sdk.exampleapp.test.pages

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.matcher.ViewMatchers.withId
import net.gini.android.health.sdk.exampleapp.R

/**
* Page Object Model for MainActivity.
* Contains all UI interactions and assertions for the main screen.
*/
class MainScreen {

/**
* Taps the "Invoices list (Material 3 Theme)" button and waits for InvoicesActivity to open.
*/
fun tapInvoicesListButton(): MainScreen {
onView(withId(R.id.invoices_screen))
.perform(scrollTo(), click())
Thread.sleep(2000)
return this
}
}
Loading
Loading