Files
minibits_wallet/src/screens/ScanScreen.tsx
T
2025-07-15 22:37:06 +02:00

350 lines
13 KiB
TypeScript

import React, {FC, useState, useEffect, useRef} from 'react'
import {
Platform,
PermissionsAndroid,
Alert,
ViewStyle,
View,
TextStyle,
} from 'react-native'
// @ts-ignore
import {Camera, CameraType} from 'react-native-camera-kit'
import { URDecoder } from '@gandlaf21/bc-ur'
import {spacing, typography, useThemeColor} from '../theme'
import {log} from '../services/logService'
import { IncomingDataType, IncomingParser } from '../services/incomingParser'
import AppError, { Err } from '../utils/AppError'
import { Button, ErrorModal, Header, Icon, Screen } from '../components'
import { LnurlUtils } from '../services/lnurl/lnurlUtils'
import { infoMessage } from '../utils/utils'
import Clipboard from '@react-native-clipboard/clipboard'
import { useStores } from '../models'
import { translate } from '../i18n'
import { MintUnit } from '../services/wallet/currency'
import { Mint } from '../models/Mint'
import { CashuUtils } from '../services/cashu/cashuUtils'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
const hasAndroidCameraPermission = async () => {
const cameraPermission = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA)
return cameraPermission !== PermissionsAndroid.RESULTS.DENIED
}
type Props = StaticScreenProps<{
unit: MintUnit
mintUrl?: string,
}>
export const ScanScreen = function ScanScreen({ route }: Props) {
const navigation = useNavigation()
const {mintsStore} = useStores()
const [shouldLoad, setShouldLoad] = useState<boolean>(false)
const [isScanned, setIsScanned] = useState<boolean>(false)
const [prevRouteName, setPrevRouteName] = useState<string>('')
const [urDecoder, setUrDecoder] = useState<URDecoder | undefined>(undefined)
const [urDecoderProgress, setUrDecoderProgress] = useState<number>(0)
const [unit, setUnit] = useState<MintUnit>('sat')
const [mint, setMint] = useState<Mint | undefined>(undefined)
const [error, setError] = useState<AppError | undefined>()
useEffect(() => {
const load = async () => {
setShouldLoad(Platform.OS !== 'android' || (await hasAndroidCameraPermission()))
const routes = navigation.getState()?.routes
let prevRoute: string = ''
if(routes && routes.length >= 2) {
prevRoute = routes[routes.length - 2].name
log.trace('prevRouteName', prevRoute)
setPrevRouteName(prevRoute)
}
const decoder = new URDecoder()
setUrDecoder(decoder)
}
load()
}, [])
useEffect(() => {
const setUnitAndMint = () => {
try {
const {unit, mintUrl} = route.params
if (!unit) throw new AppError(Err.VALIDATION_ERROR, 'Missing mint unit in route params');
setUnit(unit)
if (mintUrl) {
const mint = mintsStore.findByUrl(mintUrl)
setMint(mint)
}
} catch (e: any) {
handleError(e)
}
}
setUnitAndMint()
return () => {}
}, [])
const onReadCode = async function(event: any) {
const scanned = event.nativeEvent.codeStringValue
if (scanned.toLowerCase().startsWith("ur:")) {
if(!urDecoder) { return }
urDecoder.receivePart(scanned)
setUrDecoderProgress(Math.floor(urDecoder.estimatedPercentComplete() * 100))
if (!urDecoder.isComplete()) {
return;
}
if (urDecoder.isSuccess()) {
setIsScanned(true)
setUrDecoderProgress(0)
const ur = urDecoder.resultUR()
const decodedBuffer = ur.decodeCBOR()
const decodedData = Buffer.from(decodedBuffer).toString('utf8')
log.trace('Scanned animated', {scanned: decodedData})
return onIncomingData(decodedData)
} else {
setError(new AppError(Err.SCAN_ERROR, urDecoder.resultError()))
}
}
setIsScanned(true)
log.trace('Scanned', {scanned})
return onIncomingData(scanned)
}
const onIncomingData = async function(incoming: any) {
switch (prevRouteName) {
case 'TokenReceive':
log.trace('TokenReceive')
try {
const tokenResult = IncomingParser.findAndExtract(incoming, IncomingDataType.CASHU)
return IncomingParser.navigateWithIncomingData(tokenResult, navigation, unit, mint && mint.mintUrl)
} catch (e: any) {
const maybeLnurl = LnurlUtils.findEncodedLnurl(incoming)
if(maybeLnurl) {
try {
log.trace('Found LNURL link instead of a token', maybeLnurl, 'onIncomingData')
const encodedLnurl = LnurlUtils.extractEncodedLnurl(maybeLnurl)
if(encodedLnurl) {
await IncomingParser.navigateWithIncomingData({
type: IncomingDataType.LNURL,
encoded: encodedLnurl
}, navigation, unit, mint && mint.mintUrl)
}
return
} catch (e2: any) {
handleError(e2)
break
}
}
e.params = incoming
e.message = translate("scanReceiveExtractFail")
handleError(e)
break
}
case 'Send':
try {
const pubkey = IncomingParser.findAndExtract(incoming, IncomingDataType.NPUB_OR_HEX) // throws
return IncomingParser.navigateWithIncomingData(pubkey, navigation, unit, mint && mint.mintUrl)
} catch (e: any) {
e.params = incoming
handleError(e)
break
}
case 'LightningPay':
try {
const invoiceResult = IncomingParser.findAndExtract(incoming, IncomingDataType.INVOICE)
return IncomingParser.navigateWithIncomingData(invoiceResult, navigation, unit, mint && mint.mintUrl)
} catch (e: any) {
const maybeLnurlAddress = LnurlUtils.findEncodedLnurlAddress(incoming)
if(maybeLnurlAddress) {
try {
log.trace('Found Lightning address instead of an invoice', maybeLnurlAddress, 'onIncomingData')
const validAddress = LnurlUtils.extractLnurlAddress(maybeLnurlAddress)
if(validAddress) {
await IncomingParser.navigateWithIncomingData({
type: IncomingDataType.LNURL_ADDRESS,
encoded: validAddress
}, navigation, unit, mint && mint.mintUrl)
}
return
} catch (e3: any) {
handleError(e3)
break
}
}
const maybeLnurl = LnurlUtils.findEncodedLnurl(incoming)
if(maybeLnurl) {
try {
log.trace('Found LNURL link instead of an invoice', maybeLnurl, 'onIncomingData')
const encodedLnurl = LnurlUtils.extractEncodedLnurl(maybeLnurl)
if(encodedLnurl) {
await IncomingParser.navigateWithIncomingData({
type: IncomingDataType.LNURL,
encoded: encodedLnurl
}, navigation, unit, mint && mint.mintUrl)
}
return
} catch (e2: any) {
handleError(e2)
break
}
}
const maybeCashuPaymentRequest = CashuUtils.findEncodedCashuPaymentRequest(incoming)
if(maybeCashuPaymentRequest) {
try {
log.trace('Found Cashu Payment request instead of an invoice', maybeCashuPaymentRequest, 'onIncomingData')
const encodedPr = CashuUtils.extractEncodedCashuPaymentRequest(maybeCashuPaymentRequest)
if(encodedPr) {
await IncomingParser.navigateWithIncomingData({
type: IncomingDataType.CASHU_PAYMENT_REQUEST,
encoded: encodedPr
}, navigation, unit, mint && mint.mintUrl)
}
return
} catch (e3: any) {
handleError(e3)
break
}
}
e.params = incoming
handleError(e)
break
}
default:
try {
// generic scan button on wallet screen
const incomingData = IncomingParser.findAndExtract(incoming)
return IncomingParser.navigateWithIncomingData(incomingData, navigation, unit, mint && mint.mintUrl)
} catch (e: any) {
e.name = Err.VALIDATION_ERROR
e.params = {caller: 'onIncomingData', clipboard: incoming.slice(0, 100)}
handleError(e)
}
}
}
const onPaste = async function() {
const clipboard = await Clipboard.getString()
if (clipboard.length === 0) {
infoMessage(translate("scanScreen_onPasteEmptyClipboard"))
return
}
return onIncomingData(clipboard)
}
const handleError = function(e: AppError): void {
setError(e)
}
if(!shouldLoad) {
return null
}
return (
<Screen contentContainerStyle={$screen}>
<Header
title={urDecoderProgress > 0 ? `Progress ${urDecoderProgress} %`: 'Scan QR code'}
titleStyle={{fontFamily: typography.primary?.medium}}
leftIcon='faArrowLeft'
onLeftPress={() => navigation.goBack()}
/>
<Camera
cameraType={CameraType.Back}
scanBarcode
onReadCode={(event: any) => (isScanned ? undefined : onReadCode(event))}
hideControls
style={{flex: 1}}
/>
<View style={$bottomContainer}>
{prevRouteName !== 'SendOptions' && (
<View style={$buttonContainer}>
<Button
onPress={() => onPaste()}
LeftAccessory={() => (
<Icon icon='faPaste'/>
)}
tx='commonPaste'
preset='secondary'
style={{marginTop: spacing.medium, minWidth: 120}}
/>
</View>
)}
</View>
{error && <ErrorModal error={error} />}
</Screen>
)
}
const $screen: ViewStyle = {
flex: 1,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
}
const $modalContainer: TextStyle = {
alignItems: 'center',
}
const $addressInput: TextStyle = {
flex: 1,
borderRadius: spacing.small,
fontSize: 16,
padding: spacing.small,
marginRight: spacing.small,
alignSelf: 'stretch',
textAlignVertical: 'top',
}
const $bottomContainer: ViewStyle = {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flex: 1,
justifyContent: 'flex-end',
marginBottom: spacing.medium,
alignSelf: 'stretch',
// opacity: 0,
}