Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
132 changes: 132 additions & 0 deletions _docs/NFC_COMMANDS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# NFC Commands Plugin

Allows AAPS to execute command cascades by scanning a registered NFC tag or by manually
triggering execution from the My Tags screen.

## Screens

### My Tags tab

Lists all registered NFC tags. Each card shows:

- Tag name
- Numbered command chain (e.g. `1. LOOP STOP`, `2. BASAL STOP`)
- Tag UID chip

**Card actions (left to right):**

| Icon | Action |
|------|--------|
| ▶ Play | Open the execute confirmation dialog |
| ✎ Edit | Rename the tag |
| 🗑 Delete | Remove the tag from My Tags |

#### Manual execution

Tapping the play button shows a confirmation dialog:

```
Execute <tag name>?

Commands:
1. LOOP STOP
2. BASAL STOP
```

Pressing **Execute** runs the full cascade without requiring a physical NFC scan.
The result (success/failure + per-command messages) is written to the Log tab with
`action = "READ"`, identical to a real scan.

**Requirements:** _Allow commands via NFC_ must be enabled in plugin settings; the
same remote-command permission check applies to manual execution as to physical scans.

### Log tab

Chronological history of all tag reads (physical scans and manual executions) and
tag writes. Each entry shows action label (`Read` / `Write`), tag name, timestamp,
and the execution result message.

### Build screen

Step-by-step wizard for assembling a command cascade and writing it to a physical
NFC tag. Navigate there via the **+** FAB on the My Tags tab.

---

## Command reference

| Prefix | Examples |
|--------|---------|
| `LOOP` | `LOOP STOP`, `LOOP RESUME`, `LOOP SUSPEND 30`, `LOOP CLOSED`, `LOOP LGS` |
| `PUMP` | `PUMP CONNECT`, `PUMP DISCONNECT 30` |
| `BASAL` | `BASAL 1.5 30`, `BASAL 75% 30`, `BASAL STOP` |
| `BOLUS` | `BOLUS 5.0`, `BOLUS 5.0 MEAL` |
| `EXTENDED` | `EXTENDED 2.0 60`, `EXTENDED STOP` |
| `CARBS` | `CARBS 30` |
| `TARGET` | `TARGET MEAL`, `TARGET ACTIVITY`, `TARGET HYPO`, `TARGET STOP` |
| `PROFILE` | `PROFILE 1`, `PROFILE 1 100` |
| `AAPSCLIENT` | `AAPSCLIENT RESTART` |
| `RESTART` | `RESTART` |

Cascades execute sequentially; the first failure stops the chain.

---

## Write cooldown

When a tag is written via the Build screen, Android hardware reads it back immediately
after the write completes. To prevent that read-back from triggering command execution,
`NfcTagStore.markJustWritten()` stamps the tag UID with the current timestamp.
`prepareExecution()` checks `isJustWritten()` first (5-second window) and returns an
error if the stamp is still fresh. Subsequent scans — after the tag is removed and
re-presented — execute normally.

---

## Registering arbitrary tags (blank tags, finished Libre sensors, …)

Any NFC tag can trigger a command chain — it does not need to carry AAPS NDEF data.
Use the **+** FAB → Build screen to create a command chain, then instead of writing
to a tag, copy the resulting UID from a physical scan and save the entry with that UID.
When the phone reads the tag, `ACTION_TAG_DISCOVERED` fires as a fallback and AAPS
looks up the UID in My Tags.

**Limitation:** `ACTION_TAG_DISCOVERED` matches every NFC tag the phone reads
(credit cards, transit cards, etc.). AAPS appears in the Android app-chooser for
all tags, not just AAPS-written ones. Unknown UIDs are silently ignored (no toast).

Enable **NFC foreground priority** (see Settings) to make AAPS intercept all tags
ahead of other apps while it is in the foreground.

---

## Key classes

| Class | Responsibility |
|-------|---------------|
| `NfcCommandsPlugin` | `executeCascade` / `executeCommand` — all execution logic; `executeWithFeedback` — unified entry point that runs the cascade, appends the log entry, vibrates, and shows a toast |
| `NfcControlActivity` | Handles NFC scan intents (`NDEF_DISCOVERED` and `TAG_DISCOVERED` fallback); calls `prepareExecution` + `executeWithFeedback` |
| `NfcForegroundDispatch` | Manages `NfcAdapter.enableForegroundDispatch` lifecycle for `ComposeMainActivity`; forwards intercepted intents to `NfcControlActivity` and shows the warning dialog when the setting is enabled |
| `NfcCommandsScreen` | My Tags and Log UI; manual execution dialog |
| `NfcBuildScreen` | Command chain builder UI |
| `NfcTagStore` | `@Singleton` class injected with `SP`; persistence for tags and log; static companion methods for command templates, `buildCommand`, `buildCascade`, and `tagUidHex`; `logUpdates: Flow<Unit>` for reactive UI refresh |

---

## Settings

| Key | Description |
|-----|-------------|
| `BooleanKey.NfcAllowRemoteCommands` | Master switch — must be enabled for any command to execute |
| `BooleanKey.NfcForegroundPriority` | When enabled, AAPS intercepts all NFC tags via `enableForegroundDispatch` while the app is in the foreground, taking priority over other apps (e.g. LibreLink). A warning dialog is shown when the setting is first enabled. Dispatch is automatically disabled when AAPS moves to the background. |

---

## Tests

| Test file | Coverage |
|-----------|---------|
| `NfcCommandsPluginTest` | All command processors, `executeCascade` (success, failure, empty list, early stop), write-cooldown rejection in `prepareExecution` |
| `NfcControlActivityTest` | NDEF and TAG_DISCOVERED intent handling, silent ignore for unknown UIDs, `executeWithFeedback` delegated after successful scan |
| `NfcForegroundDispatchTest` | `onResume`/`onPause` lifecycle (preference off, no adapter, enable/disable, idempotent disable), `onNewIntent` routing (NDEF, TAG_DISCOVERED, null action, unrelated action), `observeWarning` subscription and dialog send/suppress logic |
| `NfcTagStoreTest` | Tag persistence, log persistence (success, failure, pruning, ordering), `markJustWritten`/`isJustWritten` (fresh, expired, unknown UID, case-insensitive) |
12 changes: 6 additions & 6 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,10 @@ println("isMaster: ${isMaster()}")
println("gitAvailable: ${gitAvailable()}")
println("allCommitted: ${allCommitted()}")
println("-------------------")
if (!gitAvailable()) {
throw GradleException("GIT system is not available. On Windows try to run Android Studio as an Administrator. Check if GIT is installed and Studio have permissions to use it")
}
if (isMaster() && !allCommitted()) {
throw GradleException("There are uncommitted changes. Clone sources again as described in wiki and do not allow gradle update")
}
// if (!gitAvailable()) {
// throw GradleException("GIT system is not available. On Windows try to run Android Studio as an Administrator. Check if GIT is installed and Studio have permissions to use it")
// }
// if (isMaster() && !allCommitted()) {
// throw GradleException("There are uncommitted changes. Clone sources again as described in wiki and do not allow gradle update")
// }

22 changes: 22 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Permission required to use the phone's NFC hardware -->
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
Expand Down Expand Up @@ -73,6 +75,26 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- NfcControlActivity processes NFC tags with AAPS commands.
The device must be unlocked before the activity can run.
NDEF_DISCOVERED fires for tags carrying the app MIME type.
TAG_DISCOVERED is a fallback for blank tags, finished Libre sensors,
and any other tag whose UID is registered in My Tags. -->
<activity
android:name="app.aaps.plugins.main.general.nfcCommands.NfcControlActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/vnd.app.aaps.command" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.TAG_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<!-- Receive new BG readings from other local apps -->
<receiver
android:name="app.aaps.receivers.DataReceiver"
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/kotlin/app/aaps/ComposeMainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ class ComposeMainActivity : AppCompatActivity() {
private var requestMultiplePermissions: ActivityResultLauncher<Array<String>>? = null
private var onPermissionResultDenied: ((List<String>) -> Unit)? = null

private val nfcForegroundDispatch by lazy { app.aaps.plugins.main.general.nfcCommands.NfcForegroundDispatch(this, preferences) }

// ViewModels (Hilt-provided via @HiltViewModel)
private val mainViewModel: MainViewModel by viewModels()
private val manageViewModel: ManageViewModel by viewModels()
Expand Down Expand Up @@ -839,10 +841,21 @@ class ComposeMainActivity : AppCompatActivity() {

override fun onResume() {
super.onResume()
nfcForegroundDispatch.onResume()
if (!config.appInitialized) return
refreshOnResume()
}

override fun onPause() {
nfcForegroundDispatch.onPause()
super.onPause()
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
nfcForegroundDispatch.onNewIntent(intent)
}

private fun updateButtons() {
permissionsViewModel.refresh()
}
Expand All @@ -868,6 +881,7 @@ class ComposeMainActivity : AppCompatActivity() {
lifecycleScope.launch {
preferences.observe(StringKey.GeneralLanguage).drop(1).collect { recreate() }
}
nfcForegroundDispatch.observeWarning(lifecycleScope, rxBus, rh)
}

private fun setupWakeLock() {
Expand Down
9 changes: 8 additions & 1 deletion app/src/main/kotlin/app/aaps/di/PluginsListModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import app.aaps.plugins.constraints.safety.SafetyPlugin
import app.aaps.plugins.constraints.signatureVerifier.SignatureVerifierPlugin
import app.aaps.plugins.constraints.storage.StorageConstraintPlugin
import app.aaps.plugins.constraints.versionChecker.VersionCheckerPlugin
import app.aaps.plugins.main.general.nfcCommands.NfcCommandsPlugin
import app.aaps.plugins.main.general.persistentNotification.PersistentNotificationPlugin
import app.aaps.plugins.main.iob.iobCobCalculator.IobCobCalculatorPlugin

Expand Down Expand Up @@ -160,6 +161,12 @@ abstract class PluginsListModule {
@IntKey(280)
abstract fun bindSmsCommunicatorPlugin(plugin: SmsCommunicatorPlugin): PluginBase

@Binds
@NotNSClient
@IntoMap
@IntKey(285)
abstract fun bindNfcCommandsPlugin(plugin: NfcCommandsPlugin): PluginBase

@Binds
@APS
@IntoMap
Expand Down Expand Up @@ -363,4 +370,4 @@ abstract class PluginsListModule {

@Qualifier
annotation class Unfinished
}
}
1 change: 1 addition & 0 deletions core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ enum class Sources {
VirtualPump,
Random,
SMS, //From SMS plugin
NfcCommands, //From NFC Commands plugin
Treatments, //From Treatments plugin
Wear, //From Wear plugin
Food, //From Food plugin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum class LTag(val tag: String, val defaultValue: Boolean = true, val requiresR
GLUCOSE("GLUCOSE", defaultValue = false),
HTTP("HTTP"),
LOCATION("LOCATION"),
NFC("NFC"),
NOTIFICATION("NOTIFICATION"),
NSCLIENT("NSCLIENT"),
OHUPLOADER("OHUPLOADER"),
Expand All @@ -31,4 +32,4 @@ enum class LTag(val tag: String, val defaultValue: Boolean = true, val requiresR
WIDGET("WIDGET"),
WORKER("WORKER"),
XDRIP("XDRIP")
}
}
2 changes: 2 additions & 0 deletions core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ enum class BooleanKey(

SmsAllowRemoteCommands("smscommunicator_remotecommandsallowed", false, R.string.pref_title_sms_allow_remote_commands),
SmsReportPumpUnreachable("smscommunicator_report_pump_unreachable", true, R.string.pref_title_sms_report_pump_unreachable, R.string.pref_summary_sms_report_pump_unreachable),
NfcAllowRemoteCommands("nfccommunicator_remotecommandsallowed", false, R.string.pref_title_nfc_allow_remote_commands),
NfcForegroundPriority("nfccommunicator_foreground_priority", false, R.string.pref_title_nfc_foreground_priority),

VirtualPumpStatusUpload("virtualpump_uploadstatus", false, R.string.pref_title_virtual_pump_status_upload, showInNsClientMode = false),
NsClientUploadData("ns_upload", true, R.string.pref_title_ns_upload_data, R.string.pref_summary_ns_upload_data, showInNsClientMode = false, hideParentScreenIfHidden = true),
Expand Down
2 changes: 2 additions & 0 deletions core/keys/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@
<string name="pref_title_sms_allow_remote_commands">Allow remote commands via SMS</string>
<string name="pref_title_sms_report_pump_unreachable">Report pump unreachable via SMS</string>
<string name="pref_summary_sms_report_pump_unreachable">Send SMS notification when pump becomes unreachable.</string>
<string name="pref_title_nfc_allow_remote_commands">Allow commands via NFC</string>
<string name="pref_title_nfc_foreground_priority">NFC foreground priority</string>

<!-- Virtual pump preferences -->
<string name="pref_title_virtual_pump_status_upload">Upload pump status to NS</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package app.aaps.core.ui.compose.icons

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp

val IcPluginNfc: ImageVector by lazy {
ImageVector.Builder(
name = "IcPluginNfc",
defaultWidth = 48.dp,
defaultHeight = 48.dp,
viewportWidth = 24f,
viewportHeight = 24f,
).apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1.0f,
stroke = null,
strokeAlpha = 1.0f,
strokeLineWidth = 1.0f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Miter,
strokeLineMiter = 1.0f,
) {
// Outer card border (clockwise winding)
moveTo(20f, 2f)
lineTo(4f, 2f)
curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f)
verticalLineToRelative(16f)
curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f)
horizontalLineToRelative(16f)
curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f)
lineTo(22f, 4f)
curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f)
close()
// Inner square cutout (counter-clockwise winding creates hole)
moveTo(20f, 20f)
lineTo(4f, 20f)
lineTo(4f, 4f)
horizontalLineToRelative(16f)
verticalLineToRelative(16f)
close()
// NFC chip symbol
moveTo(18f, 6f)
horizontalLineToRelative(-5f)
curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f)
verticalLineToRelative(2.28f)
curveToRelative(-0.6f, 0.35f, -1f, 0.98f, -1f, 1.72f)
curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f)
reflectiveCurveToRelative(2f, -0.9f, 2f, -2f)
curveToRelative(0f, -0.74f, -0.4f, -1.38f, -1f, -1.72f)
lineTo(13f, 8f)
horizontalLineToRelative(3f)
verticalLineToRelative(8f)
lineTo(8f, 16f)
lineTo(8f, 8f)
horizontalLineToRelative(2f)
lineTo(10f, 6f)
lineTo(6f, 6f)
verticalLineToRelative(12f)
horizontalLineToRelative(12f)
lineTo(18f, 6f)
close()
}
}.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

package app.aaps.core.ui.compose.preference

import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

import androidx.compose.ui.res.stringResource
import app.aaps.core.keys.interfaces.PreferenceItem
import app.aaps.core.keys.interfaces.PreferenceKey
import app.aaps.core.keys.interfaces.PreferenceVisibilityContext
Expand Down Expand Up @@ -42,6 +43,18 @@ fun AdaptivePreferenceList(
)
}

is PreferenceActionItem -> {
Preference(
title = { Text(stringResource(item.titleResId)) },
summary = if (item.summaryResId != 0) {
{ Text(stringResource(item.summaryResId)) }
} else {
null
},
onClick = item.onAction,
)
}

is PreferenceSubScreenDef -> {
// Subscreens are handled by PreferenceContentExtensions as nested collapsible sections
// Not rendered here in the flat list
Expand Down
Loading
Loading