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
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package to.bitkit.ui.components

import androidx.compose.foundation.layout.size
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import com.google.zxing.BinaryBitmap
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import to.bitkit.R
import to.bitkit.test.annotations.ComposeUi
import to.bitkit.ui.theme.AppThemeSurface
import kotlin.test.assertEquals

@HiltAndroidTest
@ComposeUi
class QrCodeImageTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)

@get:Rule
val compose = createComposeRule()

@Before
fun setup() {
hiltRule.inject()
}

@Test
fun displayedPaymentCodesDecodeWithLogoAtCompactAndRegularSizes() {
val address = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E"
val usdt = "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=$address"
val cases = listOf(
usdt to R.drawable.tether_circle,
"$usdt&uint256=3500000" to R.drawable.tether_circle,
"bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" to R.drawable.ic_btc_circle,
)
var renderedContent: String? = null
val payload = mutableStateOf(cases.first())
val width = mutableStateOf(120.dp)
compose.setContent {
AppThemeSurface {
QrCodeImage(
content = payload.value.first,
logoPainter = painterResource(payload.value.second),
onBitmapGenerated = { renderedContent = it?.let { payload.value.first } },
modifier = Modifier.size(width.value).testTag("DisplayedQr")
)
}
}
for (size in listOf(120.dp, 160.dp, 240.dp, 320.dp)) {
for (case in cases) {
compose.runOnIdle {
payload.value = case
width.value = size
}
compose.waitUntil(5000) { renderedContent == case.first }
compose.mainClock.advanceTimeBy(500)
val bitmap = compose.onNodeWithTag("DisplayedQr", useUnmergedTree = true)
.captureToImage().asAndroidBitmap()
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels)
assertEquals(case.first, QRCodeReader().decode(BinaryBitmap(HybridBinarizer(source))).text)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,37 @@

package to.bitkit.ui.screens.paymentrequests

import android.content.res.Configuration
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasAnyDescendant
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.persistentListOf
import org.junit.After
import org.junit.Before
Expand All @@ -24,6 +47,8 @@ import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.viewmodels.AmountInputViewModel
import java.util.Locale
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.ExperimentalTime
import kotlin.time.Instant

Expand All @@ -50,7 +75,10 @@ class PaymentRequestAmountLocaleTest {
val amountInput = AmountInputViewModel(AmountInputHandler.stub())
composeTestRule.setContent {
AppThemeSurface {
CompositionLocalProvider(LocalInspectionMode provides true) {
CompositionLocalProvider(
LocalInspectionMode provides true,
LocalConfiguration provides Configuration(LocalConfiguration.current).apply { setLocale(Locale.GERMANY) },
) {
PaymentRequestAmountContent(
amountInputViewModel = amountInput,
initialDraft = PaykitPaymentRequestDraft(
Expand All @@ -76,8 +104,105 @@ class PaymentRequestAmountLocaleTest {
composeTestRule.onNodeWithTag("N5").performClick()
composeTestRule.onNodeWithTag("N0").performClick()
composeTestRule.onNodeWithText("150,50", useUnmergedTree = true).assertIsDisplayed()
composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").performClick()
composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").performScrollTo().performClick()

assertEquals(PaykitAmount(PaykitAsset.USD, 15_050uL), submitted?.amount)
}

@Test
fun amountActionsStayAlignedAndReachableOnCompactScreens() {
val configuration = mutableStateOf(Triple(320, 2f, PaykitAsset.USD))
val height = mutableStateOf(468)
val amountInput = AmountInputViewModel(AmountInputHandler.stub())
composeTestRule.setContent {
val (width, scale, asset) = configuration.value
CompositionLocalProvider(
LocalDensity provides Density(2f, scale),
LocalInspectionMode provides true,
LocalConfiguration provides Configuration(LocalConfiguration.current).apply { setLocale(Locale.GERMANY) },
) {
AppThemeSurface {
PaymentRequestAmountContent(
amountInputViewModel = amountInput,
initialDraft = PaykitPaymentRequestDraft(
amount = PaykitAmount(asset, 150uL),
note = "",
expiresAt = Instant.fromEpochSeconds(2_000_000_000),
),
contact = null,
onBack = {},
onContinue = {},
modifier = Modifier.requiredSize(width.dp, height.value.dp)
)
}
}
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("PaymentRequestAmountField").assertIsDisplayed()
composeTestRule.onRoot().performKeyInput {
pressKey(Key.Backspace)
pressKey(Key.Five)
pressKey(Key.Five)
}
composeTestRule.onNodeWithText("1,55", useUnmergedTree = true).assertIsDisplayed()
for ((width, screenHeight) in listOf(320 to 468, 320 to 520, 360 to 568, 375 to 640)) {
for (scale in listOf(1f, 1.6f, 2f)) {
var bitcoinPositions = emptyList<Float>()
for (asset in listOf(PaykitAsset.BTC, PaykitAsset.USD)) {
composeTestRule.runOnIdle {
height.value = screenHeight
configuration.value = Triple(width, scale, asset)
}
composeTestRule.waitForIdle()
composeTestRule.onNode(
hasScrollAction() and hasAnyDescendant(hasTestTag("PaymentRequestAmountField"))
).performSemanticsAction(SemanticsActions.ScrollBy) { it(0f, -10_000f) }
val amount = composeTestRule.onNodeWithTag("PaymentRequestAmountField").getUnclippedBoundsInRoot()
val actions = composeTestRule.onNodeWithTag("PaymentRequestAmountActions").getUnclippedBoundsInRoot()
val divider = composeTestRule.onNodeWithTag("PaymentRequestAmountDivider").getUnclippedBoundsInRoot()
val lastControl = if (asset == PaykitAsset.BTC) "PaymentRequestAmountUnit" else "PaymentRequestAssetUSD"
val control = composeTestRule.onNodeWithTag(lastControl).getUnclippedBoundsInRoot()
assertEquals(divider.left.value, amount.left.value, 0.5f)
assertEquals(divider.right.value, control.right.value, 0.5f)
assertTrue(actions.top >= amount.bottom && actions.top - amount.bottom <= 17.dp)
assertTrue(divider.top >= actions.bottom)
val positions = listOf(
actions.top.value,
divider.top.value,
composeTestRule.onNodeWithTag("N1").getUnclippedBoundsInRoot().top.value,
composeTestRule.onNodeWithTag("N0").getUnclippedBoundsInRoot().top.value,
)
if (asset == PaykitAsset.BTC) {
bitcoinPositions = positions
} else {
composeTestRule.onNodeWithTag("PaymentRequestAmountUnit").assertDoesNotExist()
bitcoinPositions.zip(positions).forEach { (bitcoin, usd) ->
assertEquals(bitcoin, usd, 1f, "Controls must not move when denomination changes")
}
}
val extraKey = if (asset == PaykitAsset.BTC) "N000" else "NDecimal"
for (tag in (0..9).map { "N$it" } + extraKey + "NRemove") {
composeTestRule.onNodeWithTag(tag)
.performScrollTo().assertIsDisplayed().assertHeightIsAtLeast(50.dp)
if (tag != "NRemove") {
val layouts = mutableListOf<TextLayoutResult>()
val text = when (tag) {
"NDecimal" -> ","
"N000" -> "000"
else -> tag.removePrefix("N")
}
composeTestRule.onNode(
hasText(text) and hasAnyAncestor(hasTestTag(tag)), useUnmergedTree = true
).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) }
assertTrue(layouts.isNotEmpty())
assertFalse(layouts.single().didOverflowHeight, "$tag must fit vertically at $scale font scale")
}
}
composeTestRule.onNodeWithTag("PaymentRequestAmountContinue")
.performScrollTo().assertIsDisplayed().assertHeightIsAtLeast(56.dp)
}
}
}
}

}
8 changes: 5 additions & 3 deletions app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
Expand Down Expand Up @@ -70,7 +71,7 @@ fun QrCodeImage(
val tooltipState = rememberTooltipState()
val coroutineScope = rememberCoroutineScope()

Box(
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier
.aspectRatio(1f)
Expand Down Expand Up @@ -131,17 +132,18 @@ fun QrCodeImage(
}

logoPainter?.let {
val logoSize = minOf(68.dp, minOf(maxWidth, maxHeight) * 0.2f)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(68.dp)
.size(logoSize)
.background(Color.White, shape = CircleShape)
.align(Alignment.Center)
) {
Image(
painter = it,
contentDescription = null,
modifier = Modifier.size(50.dp)
modifier = Modifier.size(logoSize * (50.dp / 68.dp))
)
}
}
Expand Down
Loading
Loading