Merge pull request #118 from donetick/capacitor-nfc-tag-scanner
add NFC capabilities for native apps
This commit is contained in:
@@ -8,6 +8,35 @@ import { PushNotifications } from '@capacitor/push-notifications'
|
||||
import { focusManager } from '@tanstack/react-query'
|
||||
import { RegisterDeviceToken } from './utils/Fetcher'
|
||||
|
||||
// NFC chore deep link: donetick://chores/123?auto_complete=true
|
||||
const handleNFCChoreDeepLink = url => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
// donetick://chores/123 → host='chores', pathname='/123'
|
||||
const choreId = urlObj.pathname.slice(1)
|
||||
const autoComplete = urlObj.searchParams.get('auto_complete')
|
||||
const path = `/chores/${choreId}${autoComplete ? '?auto_complete=' + autoComplete : ''}`
|
||||
|
||||
// getLaunchUrl() persists across every WebView reload caused by window.location.href.
|
||||
// If we're already on the target page, skip to avoid an infinite reload loop.
|
||||
if (window.location.pathname + window.location.search === path) return
|
||||
|
||||
console.log('[NFC] navigating to', path)
|
||||
window.location.href = path
|
||||
} catch (error) {
|
||||
console.error('[NFC] Error handling chore deep link:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUrlOpen = url => {
|
||||
console.log('[NFC] handleUrlOpen:', url)
|
||||
if (url.startsWith('donetick://chores/')) {
|
||||
handleNFCChoreDeepLink(url)
|
||||
} else if (url.startsWith('donetick://auth/')) {
|
||||
handleOAuthDeepLink(url)
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth callback handler for deep links
|
||||
const handleOAuthDeepLink = async url => {
|
||||
console.log('OAuth deep link received:', url)
|
||||
@@ -215,16 +244,20 @@ const registerCapacitorListeners = () => {
|
||||
return
|
||||
}
|
||||
localNotificationListenerRegistration()
|
||||
|
||||
// Register deep link handler for OAuth and other deep links
|
||||
mobileApp.addListener('appUrlOpen', event => {
|
||||
console.log('App URL opened:', event.url)
|
||||
|
||||
// Handle OAuth callback
|
||||
if (event.url.startsWith('donetick://auth/')) {
|
||||
handleOAuthDeepLink(event.url)
|
||||
|
||||
// Cold-start: app was launched by tapping an NFC tag (or other deep link)
|
||||
mobileApp.getLaunchUrl().then(result => {
|
||||
if (result?.url) {
|
||||
console.log('[NFC] getLaunchUrl:', result.url)
|
||||
handleUrlOpen(result.url)
|
||||
}
|
||||
})
|
||||
|
||||
// Foreground / singleTask resume: app was already running when the tag was tapped
|
||||
mobileApp.addListener('appUrlOpen', event => {
|
||||
console.log('[NFC] appUrlOpen:', event.url)
|
||||
handleUrlOpen(event.url)
|
||||
})
|
||||
|
||||
mobileApp.addListener('appStateChange', ({ isActive }) => {
|
||||
focusManager.setFocused(isActive)
|
||||
@@ -233,6 +266,9 @@ const registerCapacitorListeners = () => {
|
||||
mobileApp.addListener('backButton', ({ canGoBack }) => {
|
||||
if (canGoBack) {
|
||||
window.history.back()
|
||||
} else if (window.location.pathname !== '/') {
|
||||
// No history (e.g. app launched directly to a chore via NFC) — go home
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
mobileApp.exitApp()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,136 @@
|
||||
import { CapacitorNfc } from '@capgo/capacitor-nfc'
|
||||
|
||||
// Encodes a URL into an NDEF URI record (TNF=0x01, type='U')
|
||||
const buildUriRecord = url => {
|
||||
const encoder = new TextEncoder()
|
||||
let prefixByte = 0x00
|
||||
let uriStr = url
|
||||
if (url.startsWith('https://')) {
|
||||
prefixByte = 0x04
|
||||
uriStr = url.slice(8)
|
||||
} else if (url.startsWith('http://')) {
|
||||
prefixByte = 0x03
|
||||
uriStr = url.slice(7)
|
||||
}
|
||||
return {
|
||||
tnf: 0x01,
|
||||
type: [0x55],
|
||||
id: [],
|
||||
payload: [prefixByte, ...Array.from(encoder.encode(uriStr))],
|
||||
}
|
||||
}
|
||||
|
||||
// Decodes a URL from an NDEF URI record payload. Returns null if not a URI record.
|
||||
export const decodeNdefUrl = record => {
|
||||
if (!record || record.tnf !== 0x01) return null
|
||||
if (record.type.length !== 1 || record.type[0] !== 0x55) return null
|
||||
const payload = record.payload
|
||||
if (!payload || payload.length === 0) return null
|
||||
const prefixes = [
|
||||
'',
|
||||
'http://www.',
|
||||
'https://www.',
|
||||
'http://',
|
||||
'https://',
|
||||
'tel:',
|
||||
'mailto:',
|
||||
]
|
||||
const prefix = prefixes[payload[0]] ?? ''
|
||||
const uri = new TextDecoder().decode(new Uint8Array(payload.slice(1)))
|
||||
return prefix + uri
|
||||
}
|
||||
|
||||
// Starts a native NFC write session. Calls onWaiting once scanning is active,
|
||||
// then onSuccess or onError when the write completes. Returns a cancel function.
|
||||
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => {
|
||||
let listener = null
|
||||
let done = false
|
||||
|
||||
const cleanup = async () => {
|
||||
if (listener) {
|
||||
await listener.remove()
|
||||
listener = null
|
||||
}
|
||||
await CapacitorNfc.stopScanning().catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
listener = await CapacitorNfc.addListener('nfcEvent', async () => {
|
||||
if (done) return
|
||||
done = true
|
||||
try {
|
||||
await CapacitorNfc.write({ records: [buildUriRecord(url)] })
|
||||
await cleanup()
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
await cleanup()
|
||||
onError(err.message || 'Failed to write to NFC tag')
|
||||
}
|
||||
})
|
||||
|
||||
await CapacitorNfc.startScanning({
|
||||
alertMessage: 'Hold your device near the NFC tag to write',
|
||||
invalidateAfterFirstRead: true,
|
||||
// Without FLAG_READER_SKIP_NDEF_CHECK (0x80), Android enumerates
|
||||
// Ndef/NdefFormatable tech so the plugin can format blank tags on write.
|
||||
androidReaderModeFlags: 0x0f, // NFC_A | NFC_B | NFC_F | NFC_V
|
||||
})
|
||||
onWaiting()
|
||||
return cleanup
|
||||
} catch (err) {
|
||||
await cleanup()
|
||||
onError(err.message || 'Failed to start NFC session')
|
||||
return async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
|
||||
// NDEF record is found, or onError on failure. Returns a cancel function.
|
||||
export const startNativeScan = async ({ onTag, onError }) => {
|
||||
let listener = null
|
||||
let done = false
|
||||
|
||||
const cleanup = async () => {
|
||||
if (listener) {
|
||||
await listener.remove()
|
||||
listener = null
|
||||
}
|
||||
await CapacitorNfc.stopScanning().catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
listener = await CapacitorNfc.addListener('nfcEvent', async event => {
|
||||
if (done) return
|
||||
const records = event.tag?.ndefMessage ?? []
|
||||
for (const record of records) {
|
||||
const url = decodeNdefUrl(record)
|
||||
if (url) {
|
||||
done = true
|
||||
await cleanup()
|
||||
onTag(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await CapacitorNfc.startScanning({
|
||||
alertMessage: 'Hold your device near the NFC tag',
|
||||
invalidateAfterFirstRead: true,
|
||||
})
|
||||
return cleanup
|
||||
} catch (err) {
|
||||
await cleanup()
|
||||
onError(err.message || 'Failed to start NFC session')
|
||||
return async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy default export for web/PWA (NDEFReader API)
|
||||
const writeToNFC = async url => {
|
||||
if ('NDEFReader' in window) {
|
||||
try {
|
||||
const ndef = new window.NDEFReader()
|
||||
await ndef.write({
|
||||
records: [{ recordType: 'url', data: url }],
|
||||
})
|
||||
alert('URL written to NFC tag successfully!')
|
||||
await ndef.write({ records: [{ recordType: 'url', data: url }] })
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
alert('Error writing to NFC tag. Please try again.')
|
||||
|
||||
@@ -158,8 +158,8 @@ const ChoreView = () => {
|
||||
document.title = 'Donetick: ' + choreData.res.name
|
||||
|
||||
setPerformers(circleMembersData.res)
|
||||
const auto_complete = searchParams.get('auto_complete')
|
||||
if (auto_complete === 'true') {
|
||||
if (searchParams.get('auto_complete') === 'true') {
|
||||
navigate({ search: '' }, { replace: true })
|
||||
handleTaskCompletion()
|
||||
}
|
||||
}, [choreData, circleMembersData])
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import DateModal from '../../Modals/Inputs/DateModal'
|
||||
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
||||
import SelectModal from '../../Modals/Inputs/SelectModal'
|
||||
import TextModal from '../../Modals/Inputs/TextModal'
|
||||
import WriteNFCModal from '../../Modals/Inputs/WriteNFCModal'
|
||||
|
||||
const getNFCUrl = choreId =>
|
||||
Capacitor.getPlatform() === 'android'
|
||||
? `donetick://chores/${choreId}`
|
||||
: `${window.location.origin}/chores/${choreId}`
|
||||
|
||||
const ChoreModals = ({
|
||||
activeModal,
|
||||
modalChore,
|
||||
@@ -65,12 +71,13 @@ const ChoreModals = ({
|
||||
<WriteNFCModal
|
||||
config={{
|
||||
isOpen: true,
|
||||
url: `${window.location.origin}/chores/${modalChore.id}`,
|
||||
url: getNFCUrl(modalChore.id),
|
||||
onClose: onClose,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{activeModal === 'nudge' && modalChore && (
|
||||
<NudgeModal
|
||||
config={{
|
||||
|
||||
@@ -1,114 +1,175 @@
|
||||
import { CopyAll } from '@mui/icons-material'
|
||||
import { Box, Button, Checkbox, Input, ListItem, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Input,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { startNativeNFCWrite } from '../../../service/NFCWriter'
|
||||
|
||||
function WriteNFCModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
|
||||
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle' | 'writing' | 'waiting_for_tag' | 'success' | 'error'
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false)
|
||||
const cancelScanRef = useRef(null)
|
||||
const isNative = Capacitor.isNativePlatform()
|
||||
|
||||
const requestNFCAccess = async () => {
|
||||
if ('NDEFReader' in window) {
|
||||
// Assuming permission request is implicit in 'write' or 'scan' methods
|
||||
setNfcStatus('idle')
|
||||
} else {
|
||||
alert('NFC is not supported by this browser.')
|
||||
}
|
||||
const getURL = () => {
|
||||
let url = config.url
|
||||
if (isAutoCompleteWhenScan) url += '?auto_complete=true'
|
||||
return url
|
||||
}
|
||||
|
||||
const writeToNFC = async url => {
|
||||
if ('NDEFReader' in window) {
|
||||
try {
|
||||
const ndef = new window.NDEFReader()
|
||||
await ndef.write({
|
||||
records: [{ recordType: 'url', data: url }],
|
||||
})
|
||||
setNfcStatus('success')
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
setNfcStatus('error')
|
||||
setErrorMessage('Error writing to NFC tag. Please try again.')
|
||||
}
|
||||
} else {
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(
|
||||
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
|
||||
)
|
||||
const handleClose = async () => {
|
||||
if (cancelScanRef.current) {
|
||||
await cancelScanRef.current()
|
||||
cancelScanRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
config.onClose()
|
||||
setNfcStatus('idle')
|
||||
setErrorMessage('')
|
||||
}
|
||||
const getURL = () => {
|
||||
let url = config.url
|
||||
if (isAutoCompleteWhenScan) {
|
||||
url = url + '?auto_complete=true'
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (cancelScanRef.current) {
|
||||
await cancelScanRef.current()
|
||||
cancelScanRef.current = null
|
||||
}
|
||||
setNfcStatus('idle')
|
||||
}
|
||||
|
||||
const writeToNFC = async () => {
|
||||
const url = getURL()
|
||||
|
||||
if (isNative) {
|
||||
setNfcStatus('writing')
|
||||
const cancel = await startNativeNFCWrite(url, {
|
||||
onWaiting: () => setNfcStatus('waiting_for_tag'),
|
||||
onSuccess: () => {
|
||||
cancelScanRef.current = null
|
||||
setNfcStatus('success')
|
||||
},
|
||||
onError: msg => {
|
||||
cancelScanRef.current = null
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(msg)
|
||||
},
|
||||
})
|
||||
cancelScanRef.current = cancel
|
||||
} else {
|
||||
if ('NDEFReader' in window) {
|
||||
try {
|
||||
setNfcStatus('writing')
|
||||
const ndef = new window.NDEFReader()
|
||||
await ndef.write({ records: [{ recordType: 'url', data: url }] })
|
||||
setNfcStatus('success')
|
||||
} catch (error) {
|
||||
console.error('Error writing to NFC tag:', error)
|
||||
setNfcStatus('error')
|
||||
setErrorMessage('Error writing to NFC tag. Please try again.')
|
||||
}
|
||||
} else {
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(
|
||||
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderBody = () => {
|
||||
if (nfcStatus === 'success') {
|
||||
return (
|
||||
<Typography level='body-md' gutterBottom>
|
||||
URL written to NFC tag successfully!
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
return url
|
||||
if (nfcStatus === 'waiting_for_tag') {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
alignItems='center'
|
||||
gap={2}
|
||||
py={3}
|
||||
>
|
||||
<CircularProgress size='lg' />
|
||||
<Typography level='body-md' textAlign='center'>
|
||||
Hold your device near the NFC tag
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography level='body-md' gutterBottom>
|
||||
{nfcStatus === 'error'
|
||||
? errorMessage
|
||||
: 'Press the button below to write to NFC.'}
|
||||
</Typography>
|
||||
<Input
|
||||
value={getURL()}
|
||||
fullWidth
|
||||
readOnly
|
||||
label='URL'
|
||||
sx={{ mt: 1 }}
|
||||
endDecorator={
|
||||
<CopyAll
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getURL())
|
||||
alert('URL copied to clipboard!')
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem>
|
||||
<Checkbox
|
||||
checked={isAutoCompleteWhenScan}
|
||||
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
|
||||
label='Auto-complete when scanned'
|
||||
/>
|
||||
</ListItem>
|
||||
<Box display='flex' justifyContent='space-around' mt={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={writeToNFC}
|
||||
fullWidth
|
||||
disabled={nfcStatus === 'writing'}
|
||||
>
|
||||
Write NFC
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
|
||||
<Typography level='h4' mb={1}>
|
||||
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
|
||||
</Typography>
|
||||
|
||||
{nfcStatus === 'success' ? (
|
||||
<Typography level='body-md' gutterBottom>
|
||||
URL written to NFC tag successfully!
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Typography level='body-md' gutterBottom>
|
||||
{nfcStatus === 'error'
|
||||
? errorMessage
|
||||
: 'Press the button below to write to NFC.'}
|
||||
</Typography>
|
||||
<Input
|
||||
value={getURL()}
|
||||
fullWidth
|
||||
readOnly
|
||||
label='URL'
|
||||
sx={{ mt: 1 }}
|
||||
endDecorator={
|
||||
<CopyAll
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getURL())
|
||||
alert('URL copied to clipboard!')
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem>
|
||||
<Checkbox
|
||||
checked={isAutoCompleteWhenScan}
|
||||
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
|
||||
label='Auto-complete when scanned'
|
||||
/>
|
||||
</ListItem>
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => writeToNFC(getURL())}
|
||||
fullWidth
|
||||
sx={{ mr: 1 }}
|
||||
disabled={nfcStatus === 'writing'}
|
||||
>
|
||||
Write NFC
|
||||
</Button>
|
||||
<Button size='lg' onClick={requestNFCAccess} variant='outlined'>
|
||||
Request Access
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{renderBody()}
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user