Merge pull request #118 from donetick/capacitor-nfc-tag-scanner

add NFC capabilities for native apps
This commit is contained in:
Mohamad Tarbin
2026-06-07 16:36:24 -04:00
committed by GitHub
16 changed files with 381 additions and 124 deletions

View File

@@ -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()
}

View File

@@ -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.')

View File

@@ -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])

View File

@@ -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={{

View File

@@ -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>
)
}