diff --git a/_docs/NFC_COMMANDS.md b/_docs/NFC_COMMANDS.md
new file mode 100644
index 000000000000..1b483e3215f8
--- /dev/null
+++ b/_docs/NFC_COMMANDS.md
@@ -0,0 +1,97 @@
+# 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 the tag name, command icons for quick identification, and the last scanned timestamp.
+- **▶ Play**: Manual execution of the command chain.
+- **Edit**: Opens Edit mode in the Build screen.
+- **🗑 Delete**: Remove the tag from the database.
+
+### Log tab
+Chronological history of interactions:
+- **Read**: Physical scan results.
+- **Write**: New tag registration.
+- **Manual**: Execution triggered from the UI.
+
+Each entry shows the tag name, timestamp, success status (color-coded), and the execution result message.
+
+### Build screen
+Wizard for assembling command chains.
+- **Tag Name**: Optional name, defaults to the first command if left blank.
+- **Commands**: Commands can be added, edited or removed.
+- **Pump Compatibility**: Basal commands (Absolute vs Percent) are automatically filtered based on the active pump's driver capabilities.
+
+---
+
+## Available Actions by Category
+
+| Category | Command | Description |
+| :--- | :--- | :--- |
+| **LOOP** | `LoopStopAction` | Stops the loop. |
+| | `LoopResumeAction` | Resumes the loop. |
+| | `LoopSuspendAction` | Suspends the loop for a specified duration. |
+| | `LoopCloseAction` | Switches to Closed Loop mode. |
+| | `LoopLgsAction` | Switches to Low Glucose Suspend mode. |
+| **PUMP** | `PumpConnectAction` | Connects/Resumes the pump. |
+| | `PumpDisconnectAction` | Disconnects/Suspends the pump for a duration. |
+| **BASAL** | `BasalCancelAction` | Cancels any active temporary basal. |
+| | `TempBasalAbsoluteAction` | Sets an absolute temporary basal rate (if supported). |
+| | `TempBasalPercentAction` | Sets a percentage temporary basal rate (if supported). |
+| **TREATMENTS** | `BolusAction` | Delivers a standard bolus (optionally marked as Meal). |
+| | `CarbsAction` | Records a carb entry. |
+| | `BolusWizardAction` | Launches BolusWizard on predefined parameters |
+| | `ExtendedSetAction` | Starts an extended bolus. |
+| | `ExtendedCancelAction` | Cancels an active extended bolus. |
+| **PROFILE**| `ProfileSwitchAction` | Switches the active profile (with percentage). |
+| **SCENES** | `RunSceneAction` | Runs a scene |
+| **TARGETS**| `TempTargetMealAction` | Sets "Eating Soon" temporary target. |
+| | `TempTargetActivityAction` | Sets "Activity" temporary target. |
+| | `TempTargetHypoAction` | Sets "Hypo" temporary target. |
+| | `TempTargetManuelAction` | Sets a "Manual" temporary target (with Glucose and duration) |
+| | `TempTargetCancelAction` | Cancels active temporary target. |
+| **SYSTEM** | `AAPSCLIENT_RESTART`| Triggers an immediate synchronization with Nightscout. (command disabled) |
+| | `RESTART` | Restarts the AAPS application. (command disabled) |
+
+---
+
+## Technical Details
+
+### User Entry Logging (UEL)
+Every successful execution is logged in the AAPS Treatments history:
+- **Source**: `Sources.NfcCommands`.
+- **Note**: Contains the **Tag Name** for traceability (allows identifying which physical tag was used).
+- **Color**: Matches the `userEntry` theme color.
+
+### Intent Handling
+- **NDEF_DISCOVERED**: For tags written by AAPS.
+- **TAG_DISCOVERED**: Fallback for unregistered/blank tags or finished sensors whose UID is manually registered.
+
+### Intent Filter Configuration
+The plugin requires a `nfc_tech_filter.xml` (usually in `app/src/main/res/xml/`) to handle various tag technologies (IsoDep, NfcA, NfcB, etc.).
+
+---
+
+## Key Files & Responsibilities
+
+| File | Core Responsibility |
+| :--- | :--- |
+| `NfcCommandsPlugin` | Main entry point; handles lifecycle, command routing, and feedback (vibration/toast). |
+| `NfcControlActivity`| Translucent activity that intercepts System Intents and displays the confirmation dialog. |
+| `NfcAction` | Abstract base class for all individual command logic. |
+| `NfcCommandCode` | Central Enum defining available commands, and categories. |
+| `NfcTagStore` | Handles JSON serialization and persistence of tags and logs in SharedPreferences. |
+| `NfcForegroundDispatch`| Manages NFC foreground priority to intercept scans while AAPS is open. |
+| `NfcBuildScreen` | UI for the command chain builder. |
+| `NfcCommandsScreen` | Main UI container for the My Tags and Log tabs. |
+| `NfcCommonUI` | Common UI (confirmation Popup, Commmand Icon display) |
+| `NfcJsonKeys` | List of json keys for command parameters |
+
+---
+
+## Settings
+- **Allow commands via NFC**: Master switch for execution.
+- **NFC foreground priority**: Prioritizes AAPS for NFC scans over other apps (e.g. LibreLink) when AAPS is in the foreground.
+- **Clear Log**: Wipes the NFC interaction history.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e10e9636bc4e..b98a333955f3 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
+// }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 199a397ae2c1..9fd60300154a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>? = null
private var onPermissionResultDenied: ((List) -> Unit)? = null
+ private val nfcForegroundDispatch by lazy { NfcForegroundDispatch(this, preferences) }
+
// ViewModels (Hilt-provided via @HiltViewModel)
private val mainViewModel: MainViewModel by viewModels()
private val manageViewModel: ManageViewModel by viewModels()
@@ -911,10 +914,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()
}
@@ -940,6 +954,7 @@ class ComposeMainActivity : AppCompatActivity() {
lifecycleScope.launch {
preferences.observe(StringKey.GeneralLanguage).drop(1).collect { recreate() }
}
+ nfcForegroundDispatch.observeWarning(lifecycleScope, rxBus, rh)
}
private fun setupWakeLock() {
@@ -1166,6 +1181,7 @@ class ComposeMainActivity : AppCompatActivity() {
ElementType.QUICK_WIZARD,
ElementType.SCENE,
ElementType.AUTOMATION,
+ ElementType.NFC,
ElementType.COB,
ElementType.SENSITIVITY,
ElementType.USER_ENTRY,
diff --git a/app/src/main/res/xml/nfc_tech_filter.xml b/app/src/main/res/xml/nfc_tech_filter.xml
new file mode 100644
index 000000000000..b6e1241a0774
--- /dev/null
+++ b/app/src/main/res/xml/nfc_tech_filter.xml
@@ -0,0 +1,5 @@
+
+
+ android.nfc.tech.NfcV
+
+
\ No newline at end of file
diff --git a/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt b/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt
index e0e79baf5805..a717f2365bb5 100644
--- a/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt
+++ b/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt
@@ -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
diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt
index b16fe4389d4a..6d8004f0e692 100644
--- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt
+++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt
@@ -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"),
NSCLIENT_SYNC("NSCLIENT_SYNC", defaultValue = false),
@@ -33,4 +34,4 @@ enum class LTag(val tag: String, val defaultValue: Boolean = true, val requiresR
WORKER("WORKER"),
XDRIP("XDRIP"),
XDRIP_SYNC("XDRIP_SYNC", defaultValue = false)
-}
\ No newline at end of file
+}
diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/navigation/ElementType.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/navigation/ElementType.kt
index 136337a25eee..bd5467e95463 100644
--- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/navigation/ElementType.kt
+++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/navigation/ElementType.kt
@@ -75,6 +75,7 @@ enum class ElementType(
PUMP(category = ElementCategory.SYSTEM, visibility = ElementVisibility { !it.isClient }),
SETTINGS(category = ElementCategory.SYSTEM, protection = ProtectionCheck.Protection.PREFERENCES),
QUICK_LAUNCH_CONFIG(category = ElementCategory.SYSTEM, searchable = true),
+ NFC(category = ElementCategory.SYSTEM),
// Navigation screens
TREATMENTS(category = ElementCategory.NAVIGATION, searchable = true),
diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt b/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt
index eea464c8006b..f20f3a4c5305 100644
--- a/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt
+++ b/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt
@@ -194,6 +194,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),
diff --git a/core/keys/src/main/res/values/strings.xml b/core/keys/src/main/res/values/strings.xml
index a8849d5aaee0..f248053191dc 100644
--- a/core/keys/src/main/res/values/strings.xml
+++ b/core/keys/src/main/res/values/strings.xml
@@ -97,6 +97,8 @@
Allow remote commands via SMS
Report pump unreachable via SMS
Send SMS notification when pump becomes unreachable.
+ Allow commands via NFC
+ NFC foreground priority
Upload pump status to NS
diff --git a/core/objects/src/main/res/drawable/ic_nfc.xml b/core/objects/src/main/res/drawable/ic_nfc.xml
new file mode 100644
index 000000000000..63435db338ee
--- /dev/null
+++ b/core/objects/src/main/res/drawable/ic_nfc.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNfc.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNfc.kt
new file mode 100644
index 000000000000..32c2d80d951b
--- /dev/null
+++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNfc.kt
@@ -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()
+}
diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementColors.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementColors.kt
index f43716490786..b02f74e4c068 100644
--- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementColors.kt
+++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementColors.kt
@@ -31,6 +31,7 @@ data class ElementColors(
val cob: Color,
val sensitivity: Color,
val automation: Color,
+ val nfc: Color,
// New fields for ElementType
val bolusWizard: Color,
val note: Color,
@@ -82,6 +83,7 @@ val LightElementColors = ElementColors(
cob = Color(0xFFFF5722), // deep orange — distinct from COB line (#FB8C00)
sensitivity = Color(0xFF008585), // teal — autosens icon color
automation = Color(0xFF66BB6A), // green — same as userEntry light
+ nfc = Color(0xFFCF8BFE), //
bolusWizard = Color(0xFF66BB6A), // moved from generalColors.calculator
note = Color(0xFFFEAF05), // was careportal
question = Color(0xFFFF9800), // distinct from note — amber/orange
@@ -132,6 +134,7 @@ val DarkElementColors = ElementColors(
cob = Color(0xFFFFAB91), // soft salmon — distinct from COB line (#FFB74D)
sensitivity = Color(0xFF008585), // teal — autosens icon color
automation = Color(0xFF6AE86D), // green — same as userEntry dark
+ nfc = Color(0xFFCF8BFE), //
bolusWizard = Color(0xFF67E86A), // moved from generalColors.calculator (night)
note = Color(0xFFFEAF05), // was careportal
question = Color(0xFFFFB74D), // distinct from note — warm amber
diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt
index 350207b23551..fe24e9b15ca5 100644
--- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt
+++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt
@@ -35,6 +35,7 @@ import app.aaps.core.ui.compose.icons.IcPluginConfigBuilder
import app.aaps.core.ui.compose.icons.IcPluginFood
import app.aaps.core.ui.compose.icons.IcPluginInsulin
import app.aaps.core.ui.compose.icons.IcPluginMaintenance
+import app.aaps.core.ui.compose.icons.IcPluginNfc
import app.aaps.core.ui.compose.icons.IcProfile
import app.aaps.core.ui.compose.icons.IcPumpBattery
import app.aaps.core.ui.compose.icons.IcPumpCartridge
@@ -96,6 +97,7 @@ fun ElementType.color(): Color = when (this) {
ElementType.PUMP -> AapsTheme.elementColors.pump
ElementType.SETTINGS,
ElementType.QUICK_LAUNCH_CONFIG -> AapsTheme.elementColors.settings
+ ElementType.NFC -> AapsTheme.elementColors.nfc
ElementType.TREATMENTS -> AapsTheme.elementColors.treatments
ElementType.STATISTICS,
@@ -157,7 +159,7 @@ fun ElementType.icon(): ImageVector = when (this) {
ElementType.EXTENDED_BOLUS -> IcExtendedBolus
ElementType.AUTOMATION,
ElementType.AUTOMATION_MANAGEMENT -> IcPluginAutomation
-
+ ElementType.NFC -> IcPluginNfc
ElementType.PUMP -> Pump
ElementType.SETTINGS -> Icons.Default.Settings
ElementType.QUICK_LAUNCH_CONFIG -> Icons.Default.Settings
@@ -225,6 +227,7 @@ fun ElementType.labelResId(): Int = when (this) {
ElementType.EXTENDED_BOLUS -> R.string.extended_bolus
ElementType.AUTOMATION -> 0 // dynamic label
ElementType.AUTOMATION_MANAGEMENT -> R.string.automation
+ ElementType.NFC -> R.string.nfccommands
ElementType.PUMP -> R.string.pump
ElementType.SETTINGS -> R.string.settings
ElementType.QUICK_LAUNCH_CONFIG -> R.string.quick_launch_configure
@@ -292,6 +295,7 @@ fun ElementType.descriptionResId(): Int = when (this) {
ElementType.AUTOMATION_MANAGEMENT -> R.string.automation_management_desc
ElementType.AUTHORIZED_CLIENTS -> R.string.authorized_clients_manage_desc
ElementType.PAIR_WITH_MASTER -> R.string.pair_with_master_manage_desc
+ ElementType.NFC -> R.string.description_nfc_communicator
ElementType.QUICK_WIZARD,
ElementType.RUNNING_MODE,
ElementType.AUTOMATION,
diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt
index be0b5411a4a4..edf66e13d61b 100644
--- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt
+++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt
@@ -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.VisibilityContext
@@ -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
diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceActionItem.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceActionItem.kt
new file mode 100644
index 000000000000..c6ed8f7af921
--- /dev/null
+++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceActionItem.kt
@@ -0,0 +1,11 @@
+package app.aaps.core.ui.compose.preference
+
+import androidx.annotation.StringRes
+import app.aaps.core.keys.interfaces.PreferenceItem
+
+data class PreferenceActionItem(
+ val key: String,
+ @StringRes val titleResId: Int,
+ @StringRes val summaryResId: Int = 0,
+ val onAction: () -> Unit,
+) : PreferenceItem
diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt
index f1e468015b2e..b703430bfb9b 100644
--- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt
+++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -17,6 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import app.aaps.core.keys.interfaces.BooleanPreferenceKey
import app.aaps.core.keys.interfaces.IntPreferenceKey
import app.aaps.core.keys.interfaces.IntentPreferenceKey
@@ -96,6 +98,18 @@ private fun RenderPreferenceItems(
}
}
+ is PreferenceActionItem -> {
+ Preference(
+ title = { Text(stringResource(item.titleResId)) },
+ summary = if (item.summaryResId != 0) {
+ { Text(stringResource(item.summaryResId)) }
+ } else {
+ null
+ },
+ onClick = item.onAction,
+ )
+ }
+
is PreferenceSubScreenDef -> {
val shouldShow = shouldShowSubScreenInline(
subScreen = item,
diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml
index 0b4e67b7ccf2..4cc124decbb3 100644
--- a/core/ui/src/main/res/values/strings.xml
+++ b/core/ui/src/main/res/values/strings.xml
@@ -490,6 +490,8 @@
Wear
Automation
Create rules that run actions automatically based on triggers
+ NFC Commands
+ Trigger AAPS commands by scanning an NFC tag.
Settings Export
Custom
Preset Settings
diff --git a/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt b/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt
index 8916473378c2..4f5fca00a0e7 100644
--- a/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt
+++ b/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt
@@ -198,6 +198,7 @@ data class UserEntry(
VirtualPump,
Random,
SMS, //From SMS plugin
+ NfcCommands, //From NFC Commands plugin
Treatments, //From Treatments plugin
Wear, //From Wear plugin
Food, //From Food plugin
diff --git a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt
index c84dedfd19e7..7be252c6ebc7 100644
--- a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt
+++ b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt
@@ -76,6 +76,7 @@ fun UserEntry.Sources.fromDb(): Sources =
UserEntry.Sources.VirtualPump -> Sources.VirtualPump
UserEntry.Sources.Random -> Sources.Random
UserEntry.Sources.SMS -> Sources.SMS
+ UserEntry.Sources.NfcCommands -> Sources.NfcCommands
UserEntry.Sources.Treatments -> Sources.Treatments
UserEntry.Sources.Wear -> Sources.Wear
UserEntry.Sources.Food -> Sources.Food
@@ -162,6 +163,7 @@ fun Sources.toDb(): UserEntry.Sources =
Sources.VirtualPump -> UserEntry.Sources.VirtualPump
Sources.Random -> UserEntry.Sources.Random
Sources.SMS -> UserEntry.Sources.SMS
+ Sources.NfcCommands -> UserEntry.Sources.NfcCommands
Sources.Treatments -> UserEntry.Sources.Treatments
Sources.Wear -> UserEntry.Sources.Wear
Sources.Food -> UserEntry.Sources.Food
diff --git a/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt
index faeaa91d4f94..64b5bb10ba13 100644
--- a/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt
+++ b/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt
@@ -65,6 +65,7 @@ import app.aaps.core.ui.compose.icons.IcPluginMM640G
import app.aaps.core.ui.compose.icons.IcPluginMaintenance
import app.aaps.core.ui.compose.icons.IcPluginMedtronic
import app.aaps.core.ui.compose.icons.IcPluginMedtrum
+import app.aaps.core.ui.compose.icons.IcPluginNfc
import app.aaps.core.ui.compose.icons.IcPluginNsClient
import app.aaps.core.ui.compose.icons.IcPluginNsClientBg
import app.aaps.core.ui.compose.icons.IcPluginObjectives
@@ -144,6 +145,7 @@ class UserEntryPresentationHelperImpl @Inject constructor(
Sources.Maintenance -> IcPluginMaintenance
Sources.Medtronic -> IcPluginMedtronic
Sources.Medtrum -> IcPluginMedtrum
+ Sources.NfcCommands -> IcPluginNfc
Sources.NSClient -> IcPluginNsClient
Sources.NSClientSource -> IcPluginNsClientBg
Sources.NSProfile -> IcPluginNsClient
@@ -234,6 +236,7 @@ class UserEntryPresentationHelperImpl @Inject constructor(
Sources.NSClient -> ElementType.AAPS.color()
Sources.NSClientSource -> ElementType.AAPS.color()
Sources.NSProfile -> ElementType.PROFILE_MANAGEMENT.color()
+ Sources.NfcCommands -> ElementType.NFC.color()
Sources.Note -> ElementType.NOTE.color()
Sources.NotificationReader -> ElementType.CGM_DEX.color()
Sources.Objectives -> ElementType.AAPS.color()
diff --git a/plugins/main/build.gradle.kts b/plugins/main/build.gradle.kts
index dfa6478f97c2..bd0188176bf9 100644
--- a/plugins/main/build.gradle.kts
+++ b/plugins/main/build.gradle.kts
@@ -26,6 +26,8 @@ dependencies {
testImplementation(project(":plugins:aps"))
testImplementation(project(":shared:tests"))
+ implementation(libs.sh.calvin.reorderable)
+
ksp(libs.com.google.dagger.compiler)
ksp(libs.com.google.dagger.hilt.compiler)
ksp(libs.com.google.dagger.android.processor)
diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt
index d05b4233b921..77352f158caf 100644
--- a/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt
+++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/PluginsModule.kt
@@ -26,4 +26,4 @@ abstract class PluginsModule {
@Binds fun bindIobCobCalculator(iobCobCalculatorPlugin: IobCobCalculatorPlugin): IobCobCalculator
}
-}
\ No newline at end of file
+}
diff --git a/plugins/main/src/main/res/drawable/ic_add_box.xml b/plugins/main/src/main/res/drawable/ic_add_box.xml
new file mode 100644
index 000000000000..d77155ec23b9
--- /dev/null
+++ b/plugins/main/src/main/res/drawable/ic_add_box.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/plugins/main/src/main/res/drawable/ic_info_outline.xml b/plugins/main/src/main/res/drawable/ic_info_outline.xml
new file mode 100644
index 000000000000..e153478843bb
--- /dev/null
+++ b/plugins/main/src/main/res/drawable/ic_info_outline.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/plugins/main/src/main/res/values/colors.xml b/plugins/main/src/main/res/values/colors.xml
new file mode 100644
index 000000000000..045e125f3d8d
--- /dev/null
+++ b/plugins/main/src/main/res/values/colors.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/NfcCommandsModule.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/NfcCommandsModule.kt
new file mode 100644
index 000000000000..8d9cf285d432
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/NfcCommandsModule.kt
@@ -0,0 +1,14 @@
+package app.aaps.plugins.sync.di
+
+import app.aaps.plugins.sync.nfcCommands.NfcControlActivity
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+@Suppress("unused")
+abstract class NfcCommandsModule {
+ @ContributesAndroidInjector abstract fun contributesNfcControlActivity(): NfcControlActivity
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/SyncModule.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/SyncModule.kt
index 9c46246bbf38..624f4a056977 100644
--- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/SyncModule.kt
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/SyncModule.kt
@@ -38,7 +38,8 @@ import dagger.hilt.components.SingletonComponent
includes = [
SyncModule.Binding::class,
SyncModule.Provide::class,
- SMSCommunicatorModule::class
+ SMSCommunicatorModule::class,
+ NfcCommandsModule::class
]
)
@InstallIn(SingletonComponent::class)
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/SyncPluginsListModule.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/SyncPluginsListModule.kt
index 922a40492f07..df1e99400364 100644
--- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/SyncPluginsListModule.kt
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/di/SyncPluginsListModule.kt
@@ -4,6 +4,7 @@ import app.aaps.core.interfaces.di.AllConfigs
import app.aaps.core.interfaces.di.NotNSClient
import app.aaps.core.interfaces.plugin.PluginBase
import app.aaps.plugins.sync.garmin.GarminPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
import app.aaps.plugins.sync.nsclientV3.NSClientV3Plugin
import app.aaps.plugins.sync.openhumans.OpenHumansUploaderPlugin
import app.aaps.plugins.sync.smsCommunicator.SmsCommunicatorPlugin
@@ -76,4 +77,10 @@ abstract class SyncPluginsListModule {
@IntoMap
@IntKey(370)
abstract fun bindGarminPlugin(plugin: GarminPlugin): PluginBase
+
+ @Binds
+ @NotNSClient
+ @IntoMap
+ @IntKey(380)
+ abstract fun bindNfcCommandsPlugin(plugin: NfcCommandsPlugin): PluginBase
}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcBuildScreen.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcBuildScreen.kt
new file mode 100644
index 000000000000..cd1f78a201e1
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcBuildScreen.kt
@@ -0,0 +1,1239 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import android.app.Activity
+import android.nfc.NdefMessage
+import android.nfc.NdefRecord
+import android.nfc.NfcAdapter
+import android.nfc.Tag
+import android.nfc.tech.Ndef
+import android.nfc.tech.NdefFormatable
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.InfiniteRepeatableSpec
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.TrendingUp
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Button
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.MultiChoiceSegmentedButtonRow
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableDoubleStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.aaps.core.data.model.GlucoseUnit
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.keys.DoubleKey
+import app.aaps.core.keys.IntKey
+import app.aaps.core.ui.compose.NumberInputRow
+import app.aaps.core.ui.compose.QuickAddButtons
+import app.aaps.core.ui.compose.ToolbarConfig
+import app.aaps.core.ui.compose.consumeOverscroll
+import app.aaps.core.ui.compose.icons.IcTtManual
+import app.aaps.core.ui.compose.navigation.color
+import app.aaps.core.ui.compose.navigation.icon
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.actions.NfcAction
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+import java.text.DecimalFormat
+import app.aaps.core.keys.R as KeysR
+import app.aaps.core.ui.R as CoreUiR
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NfcBuildScreen(
+ plugin: NfcCommandsPlugin,
+ setToolbarConfig: (ToolbarConfig) -> Unit,
+ onBack: () -> Unit,
+ onTagWritten: () -> Unit = onBack,
+ initialTagUid: String? = null,
+ initialTag: NfcCreatedTag? = null,
+) {
+ val context = LocalContext.current
+ val focusManager = LocalFocusManager.current
+ val categories = remember { NfcCategories.build(plugin) }
+
+ val profileStore by plugin.profileRepository.profile.collectAsStateWithLifecycle()
+ val profileNames = remember(profileStore) {
+ profileStore?.getProfileList()?.map { it.toString() } ?: emptyList()
+ }
+
+ val scenesJson by plugin.sceneAutomationApi.scenesFlow.collectAsStateWithLifecycle()
+ val sceneNames = remember(scenesJson) {
+ plugin.sceneAutomationApi.getScenes().map { it.id to it.name }
+ }
+
+ val chain = remember { mutableStateListOf() }
+ var tagName by remember { mutableStateOf("") }
+ var isWritingMode by remember { mutableStateOf(false) }
+ var showBlankNameDialog by remember { mutableStateOf(false) }
+ var showActionPicker by remember { mutableStateOf(false) }
+ var showDiscardConfirm by remember { mutableStateOf(false) }
+ var showOverwriteConfirm by remember { mutableStateOf(false) }
+ var overwriteUid by remember { mutableStateOf("") }
+ var overwriteExistingName by remember { mutableStateOf("") }
+
+ // Track initial state for "dirty" check
+ var initialCommandsSnap by remember { mutableStateOf>(emptyList()) }
+ var initialTagNameSnap by remember { mutableStateOf("") }
+ var isInitialized by remember { mutableStateOf(false) }
+
+ val currentCommands = chain.map { action ->
+ val p = action.getParams()
+ NfcTagStore.buildCommand(action.command, p.apply { put(NfcJsonKeys.TAG_NAME, tagName) })
+ }
+ val isDirty = isInitialized && (tagName != initialTagNameSnap || currentCommands != initialCommandsSnap)
+
+ val isEditMode = initialTag != null
+
+ LaunchedEffect(initialTagUid, initialTag) {
+ if (initialTag != null) {
+ tagName = initialTag.name
+ chain.clear()
+ initialTag.commands.forEach { cmdJson ->
+ runCatching { JSONObject(cmdJson) }.onSuccess { json ->
+ val codeName = json.optString(NfcJsonKeys.CODE)
+ val code = runCatching { NfcCommandCode.valueOf(codeName) }.getOrNull()
+ val params = json.optJSONObject(NfcJsonKeys.PARAMS) ?: JSONObject()
+ if (code != null) {
+ val action = createNfcUiAction(plugin, code, plugin.pumpBasalDurationStep())
+ action.applyParams(params)
+ chain.add(action)
+ }
+ }
+ }
+ initialTagNameSnap = initialTag.name
+ initialCommandsSnap = chain.map { action ->
+ val p = action.getParams()
+ NfcTagStore.buildCommand(action.command, p.apply { put(NfcJsonKeys.TAG_NAME, initialTag.name) })
+ }
+ isInitialized = true
+ } else if (initialTagUid != null) {
+ val tag = plugin.nfcTagStore.findTagByUid(initialTagUid)
+ if (tag != null) {
+ tagName = tag.name
+ }
+ initialTagNameSnap = tagName
+ initialCommandsSnap = emptyList()
+ isInitialized = true
+ } else {
+ initialTagNameSnap = ""
+ initialCommandsSnap = emptyList()
+ isInitialized = true
+ }
+ }
+
+ val title = if (isEditMode) stringResource(R.string.nfccommands_rename_tag_title) else stringResource(R.string.nfccommands_write_tag)
+ val backDesc = stringResource(CoreUiR.string.back)
+ val saveDesc = stringResource(CoreUiR.string.save)
+
+ val onSave: () -> Unit = {
+ if (initialTag != null) {
+ val uid = initialTag.tagUid
+ val commands = chain.map {
+ it.meta.params = it.getParams()
+ it.meta.buildCommand(it.command, tagName)
+ }
+ val name = tagName
+ plugin.nfcTagStore.saveCreatedTag(
+ NfcCreatedTag(
+ tagUid = uid,
+ name = name,
+ commands = commands,
+ createdAtMillis = initialTag.createdAtMillis,
+ lastScannedAtMillis = initialTag.lastScannedAtMillis,
+ ),
+ )
+ onTagWritten()
+ }
+ }
+
+ val attemptClose: () -> Unit = {
+ if (isDirty) showDiscardConfirm = true else onBack()
+ }
+
+ BackHandler { attemptClose() }
+
+ LaunchedEffect(title, isDirty, chain.size) {
+ setToolbarConfig(
+ ToolbarConfig(
+ title = title,
+ navigationIcon = {
+ IconButton(onClick = attemptClose) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backDesc)
+ }
+ },
+ actions = {
+ if (isEditMode) {
+ IconButton(
+ onClick = {
+ focusManager.clearFocus()
+ onSave()
+ },
+ enabled = isDirty && chain.isNotEmpty()
+ ) {
+ Icon(Icons.Default.Save, contentDescription = saveDesc)
+ }
+ }
+ },
+ ),
+ )
+ }
+
+ if (showDiscardConfirm) {
+ AlertDialog(
+ onDismissRequest = { showDiscardConfirm = false },
+ title = { Text(stringResource(R.string.nfccommands_discard_title)) },
+ text = { Text(stringResource(R.string.nfccommands_discard_message)) },
+ confirmButton = {
+ Row {
+ TextButton(onClick = {
+ showDiscardConfirm = false
+ onBack()
+ }) { Text(stringResource(CoreUiR.string.confirm)) }
+ TextButton(onClick = {
+ showDiscardConfirm = false
+ onSave()
+ }) { Text(stringResource(CoreUiR.string.save)) }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDiscardConfirm = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+ }
+
+ val coroutineScope = rememberCoroutineScope()
+ DisposableEffect(Unit) {
+ val activity = context as? Activity ?: return@DisposableEffect onDispose {}
+ val nfcAdapter =
+ NfcAdapter.getDefaultAdapter(context)
+ ?: return@DisposableEffect onDispose {}
+
+ val callback =
+ NfcAdapter.ReaderCallback { tag ->
+ if (!isWritingMode) {
+ plugin.aapsLogger.debug(LTag.NFC, "Inhibiting NFC scan in build screen (not in writing mode)")
+ return@ReaderCallback
+ }
+
+ val uid = NfcTagStore.tagUidHex(tag.id) ?: return@ReaderCallback
+ val name = tagName
+ val commands = chain.toList().map { action ->
+ val p = action.getParams()
+ NfcTagStore.buildCommand(action.command, p.apply { put(NfcJsonKeys.TAG_NAME, name) })
+ }
+
+ val existingTag = plugin.nfcTagStore.findTagByUid(uid)
+ if (existingTag != null) {
+ coroutineScope.launch(Dispatchers.Main) {
+ isWritingMode = false
+ overwriteUid = uid
+ overwriteExistingName = existingTag.name
+ showOverwriteConfirm = true
+ }
+ } else {
+ val ndefWritten = buildAndWriteNdef(tag, plugin)
+ val outcome = if (ndefWritten) WriteOutcome.NDEF_WRITTEN else WriteOutcome.GENERIC_ASSIGNED
+ val message = when (outcome) {
+ WriteOutcome.NDEF_WRITTEN -> plugin.rh.gs(R.string.nfccommands_tag_written)
+ else -> plugin.rh.gs(R.string.nfccommands_tag_assigned_generic)
+ }
+ plugin.nfcTagStore.appendLogEntry(
+ NfcLogEntry(
+ timestamp = System.currentTimeMillis(),
+ tagName = name,
+ action = "WRITE",
+ success = true,
+ message = message,
+ ),
+ )
+ // Always assign by UID — NDEF write is best-effort
+ plugin.nfcTagStore.saveCreatedTag(
+ NfcCreatedTag(
+ tagUid = uid,
+ name = name,
+ commands = commands,
+ createdAtMillis = System.currentTimeMillis(),
+ ),
+ )
+ plugin.nfcTagStore.markJustWritten(uid)
+ coroutineScope.launch(Dispatchers.Main) {
+ isWritingMode = false
+ if (outcome == WriteOutcome.GENERIC_ASSIGNED) {
+ Toast.makeText(context, message, Toast.LENGTH_LONG).show()
+ }
+ chain.clear()
+ onTagWritten()
+ }
+ }
+ }
+ nfcAdapter.enableReaderMode(
+ activity,
+ callback,
+ NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or
+ NfcAdapter.FLAG_READER_NFC_V or NfcAdapter.FLAG_READER_NFC_F,
+ null,
+ )
+ onDispose { nfcAdapter.disableReaderMode(activity) }
+ }
+
+ if (showBlankNameDialog) {
+ AlertDialog(
+ onDismissRequest = { showBlankNameDialog = false },
+ title = { Text(stringResource(R.string.nfccommands_blank_name_confirm_title)) },
+ text = { Text(stringResource(R.string.nfccommands_blank_name_confirm_message)) },
+ confirmButton = {
+ TextButton(onClick = {
+ showBlankNameDialog = false
+ isWritingMode = true
+ }) { Text(stringResource(R.string.nfccommands_blank_name_confirm_write_anyway)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { showBlankNameDialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+ }
+
+ if (showOverwriteConfirm) {
+ AlertDialog(
+ onDismissRequest = { showOverwriteConfirm = false },
+ title = { Text(stringResource(R.string.nfccommands_tag_already_registered_title)) },
+ text = {
+ val newName = tagName.ifBlank {
+ chain.firstOrNull()?.meta?.labelResId?.let { stringResource(it) } ?: ""
+ }
+ Text(
+ stringResource(
+ R.string.nfccommands_tag_already_registered_message,
+ overwriteExistingName,
+ newName
+ )
+ )
+ },
+ confirmButton = {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(onClick = {
+ showOverwriteConfirm = false
+ onBack()
+ }) {
+ Text(stringResource(R.string.nfccommands_discard_and_exit))
+ }
+ TextButton(onClick = {
+ showOverwriteConfirm = false
+ val name = tagName
+ val commands = chain.toList().map { action ->
+ val p = action.getParams()
+ NfcTagStore.buildCommand(action.command, p.apply { put(NfcJsonKeys.TAG_NAME, name) })
+ }
+ plugin.nfcTagStore.saveCreatedTag(
+ NfcCreatedTag(
+ tagUid = overwriteUid,
+ name = name,
+ commands = commands,
+ createdAtMillis = System.currentTimeMillis(),
+ ),
+ )
+ plugin.nfcTagStore.markJustWritten(overwriteUid)
+ plugin.nfcTagStore.appendLogEntry(
+ NfcLogEntry(
+ timestamp = System.currentTimeMillis(),
+ tagName = name,
+ action = "WRITE",
+ success = true,
+ message = plugin.rh.gs(R.string.nfccommands_tag_reassigned),
+ ),
+ )
+ chain.clear()
+ onTagWritten()
+ }) {
+ Text(stringResource(R.string.nfccommands_overwrite))
+ }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showOverwriteConfirm = false }) {
+ Text(stringResource(R.string.nfccommands_cancel_scan))
+ }
+ },
+ )
+ }
+
+ if (isWritingMode) {
+ NfcWriteDialog(chain = chain.map { it.command.name }, onCancel = { isWritingMode = false })
+ }
+
+ if (showActionPicker) {
+ ChooseActionSheet(
+ plugin = plugin,
+ categories = categories,
+ onPick = { code ->
+ coroutineScope.launch {
+ val action = createNfcUiAction(plugin, code, plugin.pumpBasalDurationStep())
+ action.applyParams(plugin.getAction(code).getDefaultParams())
+ chain.add(action)
+ }
+ },
+ onDismiss = { showActionPicker = false }
+ )
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // Section 1: Tag name
+ OutlinedTextField(
+ value = tagName,
+ onValueChange = {
+ tagName = it
+ },
+ label = { Text(stringResource(R.string.nfccommands_tag_name_hint)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
+ )
+
+ if (initialTag != null) {
+ Text(
+ text = "${stringResource(R.string.nfccommands_tag_id_label)} ${initialTag.tagUid}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
+ } else if (initialTagUid != null) {
+ Text(
+ text = "${stringResource(R.string.nfccommands_tag_id_label)} $initialTagUid",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
+ }
+
+ SectionDivider(label = stringResource(R.string.nfccommands_chain_title))
+
+ // Section 2: Command chain (Editable list)
+ if (chain.isEmpty()) {
+ Text(
+ text = stringResource(R.string.nfccommands_cascade_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
+ )
+ } else {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ chain.forEachIndexed { index, action ->
+ InlineActionCard(
+ plugin = plugin,
+ action = action,
+ profileNames = profileNames,
+ sceneNames = sceneNames,
+ onRemove = { chain.removeAt(index) }
+ )
+ }
+ }
+ }
+
+ // Section 3: Add Action Button
+ OutlinedButton(
+ onClick = { showActionPicker = true },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(CoreUiR.string.add))
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ // Section 4: Register button
+ if (!isEditMode) {
+ Button(
+ onClick = {
+ if (tagName.isBlank()) showBlankNameDialog = true else isWritingMode = true
+ },
+ enabled = chain.isNotEmpty(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.nfccommands_write_tag))
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+ }
+}
+
+@Composable
+private fun SectionDivider(label: String) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 4.dp),
+ ) {
+ HorizontalDivider(Modifier.weight(1f))
+ Text(
+ text = label,
+ modifier = Modifier.padding(horizontal = 8.dp),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ HorizontalDivider(Modifier.weight(1f))
+ }
+}
+
+@Composable
+private fun InlineActionCard(
+ plugin: NfcCommandsPlugin,
+ action: NfcUiAction,
+ profileNames: List,
+ sceneNames: List>,
+ onRemove: () -> Unit
+) {
+ val meta = action.meta
+
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = CardDefaults.elevatedShape,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ imageVector = meta.icon,
+ contentDescription = null,
+ tint = meta.customIconColor?.invoke() ?: meta.elementType.color(),
+ modifier = Modifier.size(20.dp)
+ )
+ meta.secondaryIcon?.let { secondaryIcon ->
+ Icon(
+ imageVector = secondaryIcon,
+ contentDescription = null,
+ tint = meta.secondaryIconColor?.invoke() ?: meta.elementType.color(),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ Spacer(modifier = Modifier.size(2.dp))
+ Text(
+ text = stringResource(meta.labelResId),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) {
+ Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
+ }
+ }
+ Column(modifier = Modifier.padding(bottom = 8.dp)) {
+ action.EditContent(profileNames, sceneNames) {
+ action.meta.params = action.getParams()
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+private fun ChooseActionSheet(
+ plugin: NfcCommandsPlugin,
+ categories: List,
+ onPick: (NfcCommandCode) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .padding(bottom = 24.dp)
+ .consumeOverscroll()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = stringResource(CoreUiR.string.add) + " Action",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold
+ )
+ Spacer(Modifier.height(16.dp))
+ categories.forEach { cat ->
+ Text(
+ text = stringResource(cat.labelResId),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(vertical = 6.dp)
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ cat.commands.forEach { code ->
+ val action = remember(code) { plugin.getAction(code) }
+ AssistChip(
+ onClick = {
+ onPick(code)
+ onDismiss()
+ },
+ label = { Text(stringResource(action.labelResId)) },
+ leadingIcon = {
+ Icon(
+ imageVector = action.icon,
+ contentDescription = null,
+ tint = action.customIconColor?.invoke() ?: action.elementType.color(),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ )
+ }
+ }
+ Spacer(Modifier.height(12.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun NfcWriteDialog(
+ chain: List,
+ onCancel: () -> Unit,
+) {
+ val infiniteTransition = rememberInfiniteTransition(label = "nfc_pulse")
+ val ring1Scale by infiniteTransition.animateFloat(
+ initialValue = 0.5f,
+ targetValue = 1.0f,
+ animationSpec = InfiniteRepeatableSpec(tween(900, delayMillis = 0), RepeatMode.Restart),
+ label = "ring1",
+ )
+ val ring2Scale by infiniteTransition.animateFloat(
+ initialValue = 0.5f,
+ targetValue = 1.0f,
+ animationSpec = InfiniteRepeatableSpec(tween(900, delayMillis = 200), RepeatMode.Restart),
+ label = "ring2",
+ )
+ val ring3Scale by infiniteTransition.animateFloat(
+ initialValue = 0.5f,
+ targetValue = 1.0f,
+ animationSpec = InfiniteRepeatableSpec(tween(900, delayMillis = 400), RepeatMode.Restart),
+ label = "ring3",
+ )
+
+ AlertDialog(
+ onDismissRequest = {},
+ title = {
+ Text(
+ stringResource(R.string.nfccommands_write_ready),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ },
+ text = {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(
+ modifier =
+ Modifier
+ .size(120.dp)
+ .padding(8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ val ringColor = MaterialTheme.colorScheme.primary
+ Surface(
+ shape = CircleShape,
+ border = BorderStroke(2.dp, ringColor),
+ color = Color.Transparent,
+ modifier =
+ Modifier
+ .size(120.dp)
+ .scale(ring3Scale)
+ .alpha(1f - ring3Scale),
+ ) {}
+ Surface(
+ shape = CircleShape,
+ border = BorderStroke(2.dp, ringColor),
+ color = Color.Transparent,
+ modifier =
+ Modifier
+ .size(80.dp)
+ .scale(ring2Scale)
+ .alpha(1f - ring2Scale),
+ ) {}
+ Surface(
+ shape = CircleShape,
+ border = BorderStroke(2.dp, ringColor),
+ color = Color.Transparent,
+ modifier =
+ Modifier
+ .size(40.dp)
+ .scale(ring1Scale)
+ .alpha(1f - ring1Scale),
+ ) {}
+ }
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text =
+ chain
+ .mapIndexed { i, cmd ->
+ stringResource(R.string.nfccommands_cascade_step_label, i + 1, cmd)
+ }.joinToString("\n"),
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = onCancel) { Text(stringResource(R.string.cancel)) }
+ },
+ )
+}
+
+private fun buildAndWriteNdef(
+ tag: Tag,
+ plugin: NfcCommandsPlugin,
+): Boolean {
+ val record = NdefRecord.createMime(NfcTagStore.MIME_TYPE, ByteArray(0))
+ val message = NdefMessage(arrayOf(record))
+ return try {
+ val ndef = Ndef.get(tag)
+ if (ndef != null) {
+ ndef.connect()
+ ndef.use {
+ it.writeNdefMessage(message)
+ true
+ }
+ } else {
+ val formatable = NdefFormatable.get(tag) ?: return false
+ formatable.connect()
+ formatable.use {
+ it.format(message)
+ true
+ }
+ }
+ } catch (e: Exception) {
+ plugin.aapsLogger.error(LTag.NFC, "Failed to write NDEF tag", e)
+ false
+ }
+}
+
+internal enum class WriteOutcome { REASSIGNED, NDEF_WRITTEN, GENERIC_ASSIGNED }
+
+internal fun resolveWriteOutcome(alreadyAssigned: Boolean, ndefWritten: Boolean): WriteOutcome = when {
+ alreadyAssigned -> WriteOutcome.REASSIGNED
+ ndefWritten -> WriteOutcome.NDEF_WRITTEN
+ else -> WriteOutcome.GENERIC_ASSIGNED
+}
+
+// ---------- NFC UI Action Models ----------
+
+interface NfcUiAction {
+ val command: NfcCommandCode
+ val meta: NfcAction
+ fun shortDescription(): String
+ @Composable
+ fun EditContent(profileNames: List, sceneNames: List>, onChange: () -> Unit)
+
+ fun applyParams(params: JSONObject)
+
+ fun getParams(): JSONObject
+}
+
+private fun createNfcUiAction(plugin: NfcCommandsPlugin, code: NfcCommandCode, durationStep: Int): NfcUiAction {
+ val action = plugin.getAction(code)
+ return GenericNfcUiAction(plugin, code, action.argType, durationStep)
+}
+
+/**
+ * Generic implementation of [NfcUiAction] that dynamically builds the UI based on [argTypes].
+ * It manages state for all possible atomic arguments (Insulin, Carbs, Duration, etc.).
+ */
+class GenericNfcUiAction(
+ val plugin: NfcCommandsPlugin,
+ override val command: NfcCommandCode,
+ val argTypes: List,
+ val durationStep: Int
+) : NfcUiAction {
+ override val meta = plugin.getAction(command)
+
+ // UI State for all possible field types
+ var units by mutableDoubleStateOf(1.0)
+ var glucose by mutableDoubleStateOf(100.0)
+ var grams by mutableIntStateOf(20)
+ var duration by mutableIntStateOf(30)
+ var percent by mutableIntStateOf(100)
+ var rate by mutableDoubleStateOf(1.0)
+ var meal by mutableStateOf(false)
+ var profileName by mutableStateOf("")
+ var sceneId by mutableStateOf("")
+
+ // Bolus Wizard calculation toggles
+ var useBg by mutableStateOf(true)
+ var useTT by mutableStateOf(true)
+ var useTrend by mutableStateOf(true)
+ var useIOB by mutableStateOf(true)
+ var useCOB by mutableStateOf(true)
+
+ override fun shortDescription() = ""
+
+ /**
+ * Serializes current UI state into a JSONObject using keys defined in [ArgType].
+ */
+ override fun getParams(): JSONObject {
+ val json = JSONObject()
+ argTypes.forEach { type ->
+ type.jsonKey?.let { key ->
+ when (type) {
+ ArgType.INSULIN -> json.put(key, units)
+ ArgType.AMOUNT_GRAMS -> json.put(key, grams)
+ ArgType.RATE -> json.put(key, rate)
+ ArgType.PERCENT -> json.put(key, percent)
+ ArgType.DURATION -> json.put(key, duration)
+ ArgType.MEAL_CHECK -> json.put(key, meal)
+ ArgType.PROFILE_NAME -> json.put(key, profileName)
+ ArgType.SCENE_ID -> json.put(key, sceneId)
+ ArgType.GLUCOSE_TARGET -> json.put(key, glucose)
+
+ ArgType.BOLUS_WIZARD_OPTIONS -> {
+ json.put(NfcJsonKeys.USE_BG, useBg)
+ json.put(NfcJsonKeys.USE_TT, useTT)
+ json.put(NfcJsonKeys.USE_TREND, useTrend)
+ json.put(NfcJsonKeys.USE_IOB, useIOB)
+ json.put(NfcJsonKeys.USE_COB, useCOB)
+ }
+
+ else -> {}
+ }
+ }
+ }
+ return json
+ }
+
+ /**
+ * Restores UI state from a JSONObject.
+ */
+ override fun applyParams(params: JSONObject) {
+ argTypes.forEach { type ->
+ type.jsonKey?.let { key ->
+ when (type) {
+ ArgType.INSULIN -> units = params.optDouble(key, 1.0)
+ ArgType.AMOUNT_GRAMS -> grams = params.optInt(key, 20)
+ ArgType.PERCENT -> percent = params.optInt(key, 100)
+ ArgType.RATE -> rate = params.optDouble(key, 1.0)
+ ArgType.DURATION -> duration = params.optInt(key, 30)
+ ArgType.MEAL_CHECK -> meal = params.optBoolean(key, false)
+ ArgType.PROFILE_NAME -> profileName = params.optString(key, "")
+ ArgType.SCENE_ID -> sceneId = params.optString(key, "")
+ ArgType.GLUCOSE_TARGET -> glucose = params.optDouble(key, 100.0)
+
+ ArgType.BOLUS_WIZARD_OPTIONS -> {
+ useBg = params.optBoolean(NfcJsonKeys.USE_BG, true)
+ useTT = params.optBoolean(NfcJsonKeys.USE_TT, true)
+ useTrend = params.optBoolean(NfcJsonKeys.USE_TREND, true)
+ useIOB = params.optBoolean(NfcJsonKeys.USE_IOB, true)
+ useCOB = params.optBoolean(NfcJsonKeys.USE_COB, true)
+ }
+
+ else -> {}
+ }
+ }
+ }
+ meta.params = params
+ }
+
+ /**
+ * Dynamically renders the input fields required for this specific action.
+ */
+ @Composable
+ override fun EditContent(profileNames: List, sceneNames: List>, onChange: () -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ if (argTypes.isEmpty()) {
+ Text(
+ text = stringResource(R.string.nfccommands_no_args_needed),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ argTypes.forEach { type ->
+ when (type) {
+ ArgType.INSULIN -> InsulinInputRow(plugin, units) { units = it; onChange() }
+ ArgType.AMOUNT_GRAMS -> AmountGramsInputRow(plugin, grams) { grams = it; onChange() }
+ ArgType.DURATION -> {
+ val range = when(command) {
+ NfcCommandCode.PUMP_DISCONNECT -> 15.0..180.0
+ NfcCommandCode.LOOP_SUSPEND -> 30.0..480.0
+ else -> durationStep.toDouble()..480.0
+ }
+ val step = when(command) {
+ NfcCommandCode.PUMP_DISCONNECT -> 15.0
+ NfcCommandCode.LOOP_SUSPEND -> 30.0
+ else -> durationStep.toDouble()
+ }
+ DurationInputRow(duration.toDouble(), range, step) { duration = it.toInt(); onChange() }
+ }
+ ArgType.RATE -> RateInputRow(rate) { rate = it; onChange() }
+ ArgType.PERCENT -> {
+ val range = if (command == NfcCommandCode.PROFILE_SWITCH) 10.0..500.0 else 0.0..200.0
+ val step = if (command == NfcCommandCode.PROFILE_SWITCH) 5.0 else 10.0
+ PercentInputRow(percent.toDouble(), range, step) { percent = it.toInt(); onChange() }
+ }
+ ArgType.MEAL_CHECK -> MealCheckRow(meal) { meal = it; onChange() }
+ ArgType.PROFILE_NAME -> NfcDropdown(
+ value = profileName.ifEmpty { profileNames.firstOrNull() ?: "" },
+ options = profileNames.map { it to it },
+ onValueChange = { profileName = it; onChange() },
+ label = stringResource(CoreUiR.string.profile)
+ )
+ ArgType.SCENE_ID -> NfcDropdown(
+ value = sceneId.ifEmpty { sceneNames.firstOrNull()?.first ?: "" },
+ options = sceneNames,
+ onValueChange = { sceneId = it; onChange() },
+ label = stringResource(CoreUiR.string.scenes)
+ )
+ ArgType.GLUCOSE_TARGET -> GlucoseInputRow(plugin, glucose) { glucose = it; onChange() }
+ ArgType.BOLUS_WIZARD_OPTIONS -> CalculatorOptions(useBg, useTT, useTrend, useIOB, useCOB,
+ onBgChange = { useBg = it; onChange() },
+ onTTChange = { useTT = it; onChange() },
+ onTrendChange = { useTrend = it; onChange() },
+ onIOBChange = { useIOB = it; onChange() },
+ onCOBChange = { useCOB = it; onChange() }
+ )
+ else -> {}
+ }
+ }
+ }
+ }
+}
+
+// ---------- Elementary Input Rows ----------
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NfcDropdown(
+ value: String,
+ options: List>,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ label: String? = null,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val displayValue = options.find { it.first == value }?.second ?: value
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ modifier = modifier
+ ) {
+ OutlinedTextField(
+ value = displayValue,
+ onValueChange = {},
+ readOnly = true,
+ label = label?.let { { Text(it) } },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+ )
+ ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ options.forEach { opt ->
+ DropdownMenuItem(
+ text = { Text(opt.second) },
+ onClick = { onValueChange(opt.first); expanded = false }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun InsulinInputRow(plugin: NfcCommandsPlugin, value: Double, onValueChange: (Double) -> Unit) {
+ val bolusStep = plugin.activePlugin.activePump.pumpDescription.bolusStep
+ NumberInputRow(
+ labelResId = CoreUiR.string.overview_insulin_label,
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = 0.0..30.0,
+ step = bolusStep,
+ decimalPlaces = 2,
+ unitLabel = stringResource(CoreUiR.string.insulin_unit_shortname)
+ )
+ InsulinQuickAddButtons(
+ increment1 = plugin.preferences.get(DoubleKey.OverviewInsulinButtonIncrement1),
+ increment2 = plugin.preferences.get(DoubleKey.OverviewInsulinButtonIncrement2),
+ increment3 = plugin.preferences.get(DoubleKey.OverviewInsulinButtonIncrement3),
+ onAddInsulin = { onValueChange((value + it).coerceIn(0.0, 30.0)) }
+ )
+}
+
+@Composable
+private fun AmountGramsInputRow(plugin: NfcCommandsPlugin, value: Int, onValueChange: (Int) -> Unit) {
+ NumberInputRow(
+ labelResId = CoreUiR.string.carbs,
+ value = value.toDouble(),
+ onValueChange = { onValueChange(it.toInt()) },
+ valueRange = 0.0..200.0,
+ step = 1.0,
+ valueFormat = DecimalFormat("0"),
+ unitLabel = stringResource(CoreUiR.string.shortgramm)
+ )
+ QuickAddButtons(
+ increment1 = plugin.preferences.get(IntKey.OverviewCarbsButtonIncrement1),
+ increment2 = plugin.preferences.get(IntKey.OverviewCarbsButtonIncrement2),
+ increment3 = plugin.preferences.get(IntKey.OverviewCarbsButtonIncrement3),
+ onAddCarbs = { onValueChange((value + it).coerceIn(0, 200)) }
+ )
+}
+
+@Composable
+private fun DurationInputRow(value: Double, range: ClosedFloatingPointRange, step: Double, onValueChange: (Double) -> Unit) {
+ NumberInputRow(
+ labelResId = CoreUiR.string.duration_label,
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = range,
+ step = step,
+ valueFormat = DecimalFormat("0"),
+ unitLabel = stringResource(KeysR.string.units_min)
+ )
+}
+
+@Composable
+private fun RateInputRow(value: Double, onValueChange: (Double) -> Unit) {
+ NumberInputRow(
+ labelResId = R.string.nfccommands_basal_rate_label,
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = 0.0..10.0,
+ step = 0.05,
+ decimalPlaces = 2,
+ unitLabel = stringResource(CoreUiR.string.profile_ins_units_per_hour)
+ )
+}
+
+@Composable
+private fun PercentInputRow(value: Double, range: ClosedFloatingPointRange = 0.0..200.0, step: Double = 10.0, onValueChange: (Double) -> Unit) {
+ NumberInputRow(
+ labelResId = CoreUiR.string.percent,
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = range,
+ step = step,
+ valueFormat = DecimalFormat("0"),
+ unitLabel = "%"
+ )
+}
+
+@Composable
+private fun MealCheckRow(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = checked, onCheckedChange = onCheckedChange)
+ Text(stringResource(R.string.nfccommands_meal_bolus))
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CalculatorOptions(
+ useBg: Boolean,
+ useTT: Boolean,
+ useTrend: Boolean,
+ useIOB: Boolean,
+ useCOB: Boolean,
+ onBgChange: (Boolean) -> Unit,
+ onTTChange: (Boolean) -> Unit,
+ onTrendChange: (Boolean) -> Unit,
+ onIOBChange: (Boolean) -> Unit,
+ onCOBChange: (Boolean) -> Unit
+) {
+ MultiChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ SegmentedButton(
+ checked = useBg,
+ onCheckedChange = {
+ onBgChange(it)
+ if (!it) onTTChange(false)
+ },
+ shape = SegmentedButtonDefaults.itemShape(0, 5),
+ icon = {}
+ ) {
+ Icon(imageVector = ElementType.BG_CHECK.icon(), contentDescription = null, modifier = Modifier.size(20.dp))
+ }
+ SegmentedButton(
+ checked = useTT,
+ onCheckedChange = {
+ onTTChange(it)
+ if (it) onBgChange(true)
+ },
+ shape = SegmentedButtonDefaults.itemShape(1, 5),
+ icon = {}
+ ) {
+ Icon(imageVector = IcTtManual, contentDescription = null, modifier = Modifier.size(20.dp))
+ }
+ SegmentedButton(
+ checked = useTrend,
+ onCheckedChange = onTrendChange,
+ shape = SegmentedButtonDefaults.itemShape(2, 5),
+ icon = {}
+ ) {
+ Icon(imageVector = Icons.AutoMirrored.Filled.TrendingUp, contentDescription = null, modifier = Modifier.size(20.dp))
+ }
+ SegmentedButton(
+ checked = useIOB,
+ onCheckedChange = {
+ onIOBChange(it)
+ if (!it) onCOBChange(false)
+ },
+ shape = SegmentedButtonDefaults.itemShape(3, 5),
+ icon = {}
+ ) {
+ Icon(imageVector = ElementType.INSULIN.icon(), contentDescription = null, modifier = Modifier.size(20.dp))
+ }
+ SegmentedButton(
+ checked = useCOB,
+ onCheckedChange = {
+ onCOBChange(it)
+ if (it) onIOBChange(true)
+ },
+ shape = SegmentedButtonDefaults.itemShape(4, 5),
+ icon = {}
+ ) {
+ Icon(imageVector = ElementType.COB.icon(), contentDescription = null, modifier = Modifier.size(20.dp))
+ }
+ }
+}
+
+@Composable
+private fun GlucoseInputRow(plugin: NfcCommandsPlugin, value: Double, onValueChange: (Double) -> Unit) {
+ val units = plugin.profileUtil.units
+ val isMmol = units == GlucoseUnit.MMOL
+ val range = if (isMmol) 3.9..13.9 else 70.0..250.0
+ val step = if (isMmol) 0.1 else 5.0
+ val decimals = if (isMmol) 1 else 0
+ val format = if (isMmol) DecimalFormat("0.0") else DecimalFormat("0")
+
+ NumberInputRow(
+ labelResId = CoreUiR.string.target_label,
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = range,
+ step = step,
+ decimalPlaces = decimals,
+ valueFormat = format,
+ unitLabel = if (isMmol) "mmol/l" else "mg/dl"
+ )
+}
+
+@Composable
+private fun InsulinQuickAddButtons(
+ increment1: Double,
+ increment2: Double,
+ increment3: Double,
+ onAddInsulin: (Double) -> Unit
+) {
+ val increments = listOf(increment1, increment2, increment3).filter { it != 0.0 }
+ if (increments.isEmpty()) return
+
+ val focusManager = LocalFocusManager.current
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ increments.forEach { amount ->
+ val label = if (amount > 0) "+$amount" else amount.toString()
+ FilledTonalButton(onClick = {
+ focusManager.clearFocus()
+ onAddInsulin(amount)
+ }) {
+ Text(label)
+ }
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandCode.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandCode.kt
new file mode 100644
index 000000000000..c09eb20b6476
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandCode.kt
@@ -0,0 +1,118 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import androidx.annotation.StringRes
+import app.aaps.plugins.sync.nfcCommands.actions.*
+import app.aaps.plugins.sync.R
+import app.aaps.core.ui.R as CoreUiR
+
+/**
+ * Defines elementary argument types for NFC Actions.
+ * These types are used by the UI to dynamically build configuration forms.
+ * [jsonKey] indicates which field in the parameters JSON this argument corresponds to.
+ */
+enum class ArgType(val jsonKey: String? = null) {
+ NONE,
+ DURATION(NfcJsonKeys.DURATION),
+ INSULIN(NfcJsonKeys.AMOUNT),
+ RATE(NfcJsonKeys.RATE),
+ PERCENT(NfcJsonKeys.PERCENT),
+ AMOUNT_GRAMS(NfcJsonKeys.AMOUNT),
+ MEAL_CHECK(NfcJsonKeys.IS_MEAL),
+ PROFILE_NAME(NfcJsonKeys.PROFILE_NAME),
+ SCENE_ID(NfcJsonKeys.SCENE_ID),
+ GLUCOSE_TARGET(NfcJsonKeys.GLUCOSE),
+ BOLUS_WIZARD_OPTIONS // Special composite type for calculator toggles
+}
+
+/**
+ * Categories for grouping NFC commands in the picker UI.
+ */
+enum class NfcCategory(@StringRes val labelResId: Int) {
+ LOOP(CoreUiR.string.loop),
+ PUMP(CoreUiR.string.pump),
+ BASAL(CoreUiR.string.basal),
+ TREATMENTS(CoreUiR.string.treatments),
+ PROFILE(CoreUiR.string.profile),
+ SCENES(CoreUiR.string.scenes),
+ TARGETS(R.string.nfccommands_cat_targets),
+ SYSTEM(R.string.nfccommands_cat_system)
+}
+
+/**
+ * Data structure representing a group of commands in the NFC action picker.
+ */
+data class NfcUiCategory(
+ val labelResId: Int,
+ val commands: List,
+)
+
+/**
+ * Registry of all available NFC commands and their factory methods.
+ * Each entry maps a command code to its corresponding [NfcAction] implementation and [NfcCategory].
+ */
+enum class NfcCommandCode(
+ val category: NfcCategory,
+ val createAction: (NfcCommandsPlugin) -> NfcAction
+) {
+ // Loop Management
+ LOOP_STOP(NfcCategory.LOOP, ::LoopStopAction),
+ LOOP_RESUME(NfcCategory.LOOP, ::LoopResumeAction),
+ LOOP_SUSPEND(NfcCategory.LOOP, ::LoopSuspendAction),
+ LOOP_CLOSED(NfcCategory.LOOP, ::LoopClosedAction),
+ LOOP_LGS(NfcCategory.LOOP, ::LoopLgsAction),
+
+ // Pump Control
+ PUMP_CONNECT(NfcCategory.PUMP, ::PumpConnectAction),
+ PUMP_DISCONNECT(NfcCategory.PUMP, ::PumpDisconnectAction),
+
+ // Basal Rate
+ BASAL_STOP(NfcCategory.BASAL, ::BasalCancelAction),
+ BASAL_ABS(NfcCategory.BASAL, ::TempBasalAbsoluteAction),
+ BASAL_PCT(NfcCategory.BASAL, ::TempBasalPercentAction),
+
+ // Treatments
+ BOLUS(NfcCategory.TREATMENTS, ::BolusAction),
+ CARBS(NfcCategory.TREATMENTS, ::CarbsAction),
+ BOLUS_WIZARD(NfcCategory.TREATMENTS, ::BolusWizardAction),
+ EXTENDED_STOP(NfcCategory.TREATMENTS, ::ExtendedCancelAction),
+ EXTENDED_SET(NfcCategory.TREATMENTS, ::ExtendedSetAction),
+
+ // Profile
+ PROFILE_SWITCH(NfcCategory.PROFILE, ::ProfileSwitchAction),
+
+ // Scenes
+ RUN_SCENE(NfcCategory.SCENES, ::RunSceneAction),
+
+ // Temporary Targets
+ TARGET_MEAL(NfcCategory.TARGETS, ::TempTargetMealAction),
+ TARGET_ACTIVITY(NfcCategory.TARGETS, ::TempTargetActivityAction),
+ TARGET_HYPO(NfcCategory.TARGETS, ::TempTargetHypoAction),
+ TARGET_MANUAL(NfcCategory.TARGETS, ::TempTargetManualAction),
+ TARGET_STOP(NfcCategory.TARGETS, ::TempTargetCancelAction),
+
+ // Maintenance
+ //AAPSCLIENT_RESTART(NfcCategory.SYSTEM, ::AapsClientRestartAction),
+ //RESTART(NfcCategory.SYSTEM, ::RestartAction);
+}
+
+/**
+ * Logic to build the list of available categories and commands based on current system state.
+ */
+object NfcCategories {
+ /**
+ * Scans all [NfcCommandCode]s and returns only those supported by the current hardware/configuration,
+ * grouped by their [NfcCategory].
+ */
+ fun build(plugin: NfcCommandsPlugin): List {
+ return NfcCommandCode.entries
+ .map { it to it.createAction(plugin) }
+ .filter { it.second.isSupported() }
+ .groupBy { it.first.category }
+ .map { (cat, pairs) ->
+ NfcUiCategory(
+ labelResId = cat.labelResId,
+ commands = pairs.map { it.first }
+ )
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandsPlugin.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandsPlugin.kt
new file mode 100644
index 000000000000..f58e2d42c039
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandsPlugin.kt
@@ -0,0 +1,324 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import android.content.Context
+import android.content.Intent
+import android.nfc.NdefMessage
+import android.nfc.NdefRecord
+import android.nfc.NfcAdapter
+import android.nfc.Tag
+import android.os.Handler
+import android.os.Looper
+import android.os.VibrationEffect
+import android.os.VibratorManager
+import android.widget.Toast
+import app.aaps.core.data.plugin.PluginType
+import app.aaps.core.interfaces.aps.Loop
+import app.aaps.core.interfaces.bolus.WizardBolusExecutor
+import app.aaps.core.interfaces.configuration.ConfigBuilder
+import app.aaps.core.interfaces.constraints.ConstraintsChecker
+import app.aaps.core.interfaces.db.PersistenceLayer
+import app.aaps.core.interfaces.logging.AAPSLogger
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.logging.UserEntryLogger
+import app.aaps.core.interfaces.insulin.Insulin
+import app.aaps.core.interfaces.iob.GlucoseStatusProvider
+import app.aaps.core.interfaces.iob.IobCobCalculator
+import app.aaps.core.interfaces.pump.BolusProgressData
+import app.aaps.core.interfaces.pump.PumpStatusProvider
+import app.aaps.core.interfaces.plugin.ActivePlugin
+import app.aaps.core.interfaces.plugin.PluginBaseWithPreferences
+import app.aaps.core.interfaces.plugin.PluginDescription
+import app.aaps.core.interfaces.profile.ProfileFunction
+import app.aaps.core.interfaces.profile.ProfileRepository
+import app.aaps.core.interfaces.profile.ProfileUtil
+import app.aaps.core.interfaces.queue.CommandQueue
+import app.aaps.core.interfaces.resources.ResourceHelper
+import app.aaps.core.interfaces.rx.bus.RxBus
+import app.aaps.core.interfaces.scenes.SceneAutomationApi
+import app.aaps.core.interfaces.scenes.SceneIconResolver
+import app.aaps.core.interfaces.utils.DateUtil
+import app.aaps.core.interfaces.utils.DecimalFormatter
+import app.aaps.core.keys.BooleanKey
+import app.aaps.core.keys.interfaces.Preferences
+import app.aaps.core.ui.compose.icons.IcPluginNfc
+import app.aaps.core.ui.compose.preference.PreferenceActionItem
+import app.aaps.core.ui.compose.preference.PreferenceSubScreenDef
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.actions.*
+import org.json.JSONObject
+import java.nio.charset.StandardCharsets
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Result of the pre-execution phase when an NFC tag is detected.
+ */
+sealed class NfcPrepareResult {
+ data class Error(val message: String) : NfcPrepareResult()
+ data class Ready(
+ val tagUid: String,
+ val tagName: String,
+ val commands: List,
+ ) : NfcPrepareResult()
+}
+
+/**
+ * Result of a single NFC action execution.
+ */
+data class NfcExecutionResult(
+ val success: Boolean,
+ val message: String,
+)
+
+/**
+ * Main plugin class for NFC Command execution.
+ * Handles the lifecycle of NFC tag scanning, command routing, and feedback.
+ */
+@Singleton
+class NfcCommandsPlugin @Inject constructor(
+ private val context: Context,
+ aapsLogger: AAPSLogger,
+ rh: ResourceHelper,
+ preferences: Preferences,
+ val nfcTagStore: NfcTagStore,
+ val constraintChecker: ConstraintsChecker,
+ val profileFunction: ProfileFunction,
+ val profileUtil: ProfileUtil,
+ val profileRepository: ProfileRepository,
+ val insulin: Insulin,
+ val activePlugin: ActivePlugin,
+ val commandQueue: CommandQueue,
+ val loop: Loop,
+ val dateUtil: DateUtil,
+ val persistenceLayer: PersistenceLayer,
+ val decimalFormatter: DecimalFormatter,
+ val configBuilder: ConfigBuilder,
+ val rxBus: RxBus,
+ val uel: UserEntryLogger,
+ val wizardBolusExecutor: WizardBolusExecutor,
+ val iobCobCalculator: IobCobCalculator,
+ val bolusProgressData: BolusProgressData,
+ val glucoseStatusProvider: GlucoseStatusProvider,
+ val sceneAutomationApi: SceneAutomationApi,
+ val sceneIconResolver: SceneIconResolver,
+) : PluginBaseWithPreferences(
+ PluginDescription()
+ .mainType(PluginType.SYNC)
+ .icon(IcPluginNfc)
+ .composeContent { NfcCommandsComposeContent(it as NfcCommandsPlugin) }
+ .pluginName(R.string.nfccommands)
+ .shortName(R.string.nfccommands_shortname)
+ .description(R.string.description_nfc_communicator),
+ ownPreferences = emptyList(),
+ aapsLogger,
+ rh,
+ preferences,
+) {
+ /** Cooldown tracker for remote boluses to prevent double-scanning issues. */
+ var lastRemoteBolusTime: Long = 0
+ private set
+
+ /** Temporary state storage for actions that need to pass data between phases (e.g. Wizard calculation to execution). */
+ private val actionStates = mutableMapOf()
+
+ fun setActionState(key: String, state: Any) { actionStates[key] = state }
+ fun getActionState(key: String): Any? = actionStates[key]
+ fun clearActionStates() { actionStates.clear() }
+
+ fun setLastRemoteBolusTime(time: Long) { lastRemoteBolusTime = time }
+
+ override fun getPreferenceScreenContent() = PreferenceSubScreenDef(
+ key = "nfccommunicator_settings",
+ titleResId = R.string.nfccommands,
+ items = listOf(
+ BooleanKey.NfcAllowRemoteCommands,
+ BooleanKey.NfcForegroundPriority,
+ PreferenceActionItem(
+ key = "nfccommunicator_clear_log",
+ titleResId = R.string.nfccommands_clear_log,
+ summaryResId = R.string.nfccommands_clear_log_summary,
+ onAction = {
+ nfcTagStore.clearLog()
+ showToast(rh.gs(R.string.nfccommands_log_cleared))
+ },
+ ),
+ ),
+ icon = pluginDescription.icon,
+ )
+
+ fun updateLastScanned(tagUid: String) { nfcTagStore.updateLastScanned(tagUid) }
+
+ /**
+ * Prepares a tag for execution by checking plugin status and registration.
+ */
+ fun prepareExecution(tagUid: String): NfcPrepareResult {
+ clearActionStates()
+ if (!isEnabled()) return NfcPrepareResult.Error(rh.gs(R.string.nfccommands_plugin_disabled))
+
+ val tag = nfcTagStore.findTagByUid(tagUid)
+ if (tag == null) {
+ aapsLogger.debug(LTag.NFC, "No registered tag found for UID: $tagUid")
+ return NfcPrepareResult.Error(rh.gs(R.string.nfccommands_tag_not_registered))
+ }
+ return NfcPrepareResult.Ready(tagUid = tagUid, tagName = tag.name, commands = tag.commands)
+ }
+
+ /**
+ * Executes a list of serialized command strings sequentially.
+ */
+ suspend fun executeCascade(commands: List): NfcExecutionResult {
+ val results = mutableListOf()
+ for (command in commands) {
+ val result = executeCommand(command)
+ results += result
+ if (!result.success) break
+ }
+ val allSuccess = results.all { it.success }
+ val message = results.joinToString("\n") { it.message }
+ return NfcExecutionResult(success = allSuccess, message = message)
+ }
+
+ /**
+ * Executes commands and provides physical (vibration) and visual (toast/log) feedback.
+ */
+ suspend fun executeWithFeedback(commands: List, tagName: String, action: String = "READ"): NfcExecutionResult {
+ val result = executeCascade(commands)
+ clearActionStates()
+ nfcTagStore.appendLogEntry(
+ NfcLogEntry(
+ timestamp = System.currentTimeMillis(),
+ tagName = tagName,
+ action = action,
+ success = result.success,
+ message = result.message,
+ ),
+ )
+ vibrate(result.success)
+ showToast(result.message)
+ return result
+ }
+
+ private fun vibrate(success: Boolean) {
+ runCatching {
+ val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager ?: return
+ val effect = if (success) {
+ VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE)
+ } else {
+ VibrationEffect.createWaveform(longArrayOf(0, 150, 100, 150), -1)
+ }
+ vm.defaultVibrator.vibrate(effect)
+ }
+ }
+
+ private fun showToast(message: String) {
+ runCatching {
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(context, message, Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+
+ /** Returns the pump's temporary basal duration step in minutes. */
+ fun pumpBasalDurationStep(): Int =
+ activePlugin.activePump.model().tbrSettings()?.durationStep ?: 60
+
+ /**
+ * Parses and executes a single serialized command string.
+ */
+ suspend fun executeCommand(command: String): NfcExecutionResult {
+ aapsLogger.debug(LTag.NFC, "Executing NFC command: $command")
+
+ runCatching { JSONObject(command) }.onSuccess { json ->
+ val codeString = json.optString(NfcJsonKeys.CODE)
+ val code = runCatching { NfcCommandCode.valueOf(codeString) }.getOrNull()
+ val params = json.optJSONObject(NfcJsonKeys.PARAMS) ?: JSONObject()
+ if (code != null) {
+ return routeAction(code, params)
+ }
+ }
+
+ return NfcExecutionResult(false, rh.gs(R.string.nfccommands_unknown_command))
+ }
+
+ fun getAction(code: NfcCommandCode): NfcAction = code.createAction(this)
+
+ private suspend fun routeAction(code: NfcCommandCode, params: JSONObject): NfcExecutionResult {
+ return requireRemoteCommands {
+ val action = getAction(code)
+ action.params = params
+ action.execute()
+ }
+ }
+
+ private suspend fun requireRemoteCommands(block: suspend () -> NfcExecutionResult): NfcExecutionResult {
+ val remoteAllowed = preferences.get(BooleanKey.NfcAllowRemoteCommands)
+ if (!remoteAllowed) {
+ return NfcExecutionResult(false, rh.gs(R.string.nfccommands_remote_command_not_allowed))
+ }
+ return block()
+ }
+
+ /**
+ * Rounds a duration value UP to the next valid pump step multiple.
+ * Ensures compatibility when a tag written for one pump is used on another.
+ */
+ fun roundUpToStep(value: Int, step: Int): Int =
+ if (value % step == 0) value else ((value / step) + 1) * step
+
+ /**
+ * Entry point for processing Android NFC Intents.
+ */
+ fun processIntent(intent: Intent?): NfcPrepareResult {
+ if (!isEnabled()) return NfcPrepareResult.Error(rh.gs(R.string.nfccommands_plugin_disabled))
+ if (intent == null) return NfcPrepareResult.Error("")
+
+ @Suppress("DEPRECATION")
+ val nfcTag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
+ if (nfcTag == null) {
+ aapsLogger.debug(LTag.NFC, "Rejected intent without physical NFC tag")
+ return NfcPrepareResult.Error("")
+ }
+
+ @Suppress("DEPRECATION")
+ return when (intent.action) {
+ NfcAdapter.ACTION_NDEF_DISCOVERED -> processNdefIntent(intent, nfcTag)
+ NfcAdapter.ACTION_TECH_DISCOVERED, NfcAdapter.ACTION_TAG_DISCOVERED -> processTagIntent(nfcTag)
+ else -> NfcPrepareResult.Error("")
+ }
+ }
+
+ private fun processNdefIntent(intent: Intent, nfcTag: Tag): NfcPrepareResult {
+ @Suppress("DEPRECATION")
+ val rawMsgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
+ if (rawMsgs.isNullOrEmpty()) return NfcPrepareResult.Error("")
+
+ val message = rawMsgs[0] as? NdefMessage ?: return NfcPrepareResult.Error("")
+ val record = message.records?.firstOrNull() ?: return NfcPrepareResult.Error("")
+
+ if (record.tnf != NdefRecord.TNF_MIME_MEDIA ||
+ String(record.type, StandardCharsets.US_ASCII) != NfcTagStore.MIME_TYPE
+ ) {
+ aapsLogger.debug(LTag.NFC, "Rejected NFC record with unexpected TNF/type")
+ return NfcPrepareResult.Error("")
+ }
+
+ val tagUid = NfcTagStore.tagUidHex(nfcTag.id) ?: return NfcPrepareResult.Error("")
+ return prepareExecutionByUid(tagUid)
+ }
+
+ private fun processTagIntent(nfcTag: Tag): NfcPrepareResult {
+ val tagUid = NfcTagStore.tagUidHex(nfcTag.id) ?: return NfcPrepareResult.Error("")
+ aapsLogger.debug(LTag.NFC, "TAG_DISCOVERED fallback, UID: $tagUid")
+ return prepareExecutionByUid(tagUid)
+ }
+
+ private fun prepareExecutionByUid(tagUid: String): NfcPrepareResult {
+ if (nfcTagStore.isJustWritten(tagUid)) return NfcPrepareResult.Error("")
+
+ val prep = prepareExecution(tagUid)
+ if (prep is NfcPrepareResult.Error) {
+ aapsLogger.debug(LTag.NFC, "Tag not registered: $tagUid")
+ }
+ return prep
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandsScreen.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandsScreen.kt
new file mode 100644
index 000000000000..0562fb337145
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandsScreen.kt
@@ -0,0 +1,489 @@
+@file:OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+
+package app.aaps.plugins.sync.nfcCommands
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import app.aaps.core.ui.compose.AapsFab
+import app.aaps.core.ui.compose.AapsSpacing
+import app.aaps.core.ui.compose.ComposablePluginContent
+import app.aaps.core.ui.compose.ToolbarConfig
+import app.aaps.core.ui.compose.navigation.color
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.text.DateFormat
+import app.aaps.plugins.sync.R
+import app.aaps.core.ui.R as CoreUiR
+
+private sealed class NfcRoute {
+ object Main : NfcRoute()
+ object Build : NfcRoute()
+}
+
+class NfcCommandsComposeContent(val plugin: NfcCommandsPlugin) : ComposablePluginContent {
+ @Composable
+ override fun Render(
+ setToolbarConfig: (ToolbarConfig) -> Unit,
+ onNavigateBack: () -> Unit,
+ onSettings: (() -> Unit)?,
+ ) {
+ var route by remember { mutableStateOf(NfcRoute.Main) }
+ var initialTab by remember { mutableIntStateOf(1) } // Default to My Tags tab
+ var editTarget by remember { mutableStateOf(null) }
+
+ when (route) {
+ NfcRoute.Main ->
+ NfcCommandsScreen(
+ plugin = plugin,
+ nfcTagStore = plugin.nfcTagStore,
+ setToolbarConfig = setToolbarConfig,
+ onNavigateBack = onNavigateBack,
+ onSettings = onSettings,
+ initialTab = initialTab,
+ onBuild = {
+ editTarget = null
+ initialTab = 1
+ route = NfcRoute.Build
+ },
+ onEdit = { tag ->
+ editTarget = tag
+ initialTab = 1
+ route = NfcRoute.Build
+ },
+ onTabChanged = { initialTab = it }
+ )
+ NfcRoute.Build ->
+ NfcBuildScreen(
+ plugin = plugin,
+ setToolbarConfig = setToolbarConfig,
+ onBack = {
+ initialTab = 1
+ route = NfcRoute.Main
+ },
+ onTagWritten = {
+ initialTab = 1
+ route = NfcRoute.Main
+ },
+ initialTag = editTarget
+ )
+ }
+ }
+}
+
+@Composable
+fun NfcCommandsScreen(
+ plugin: NfcCommandsPlugin,
+ nfcTagStore: NfcTagStore,
+ setToolbarConfig: (ToolbarConfig) -> Unit,
+ onNavigateBack: () -> Unit,
+ onSettings: (() -> Unit)?,
+ initialTab: Int,
+ onBuild: () -> Unit,
+ onEdit: (NfcCreatedTag) -> Unit,
+ onTabChanged: (Int) -> Unit,
+) {
+ val tabTitles = listOf(
+ stringResource(R.string.nfccommands_tab_log),
+ stringResource(R.string.nfccommands),
+ )
+
+ val pagerState = rememberPagerState(initialPage = initialTab) { tabTitles.size }
+ val coroutineScope = rememberCoroutineScope()
+
+ LaunchedEffect(pagerState.currentPage) {
+ onTabChanged(pagerState.currentPage)
+ }
+
+ val title = stringResource(R.string.nfccommands)
+ val backDesc = stringResource(CoreUiR.string.back)
+
+ LaunchedEffect(Unit) {
+ setToolbarConfig(
+ ToolbarConfig(
+ title = title,
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backDesc)
+ }
+ },
+ actions = {
+ if (onSettings != null) {
+ IconButton(onClick = onSettings) {
+ Icon(Icons.Default.Settings, contentDescription = stringResource(CoreUiR.string.settings))
+ }
+ }
+ },
+ ),
+ )
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
+ tabTitles.forEachIndexed { index, title ->
+ Tab(
+ selected = pagerState.currentPage == index,
+ onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
+ text = { Text(title) },
+ )
+ }
+ }
+
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.fillMaxSize()
+ ) { page ->
+ when (page) {
+ 0 -> NfcLogScreen(nfcTagStore = nfcTagStore)
+ 1 -> NfcTagsScreen(plugin = plugin, nfcTagStore = nfcTagStore, onBuild = onBuild, onEdit = onEdit)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun NfcLogScreen(nfcTagStore: NfcTagStore) {
+ var entries by remember { mutableStateOf>(emptyList()) }
+
+ LaunchedEffect(Unit) {
+ nfcTagStore.logUpdates.collect {
+ entries = nfcTagStore.loadLog()
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ entries = nfcTagStore.loadLog()
+ }
+
+ if (entries.isEmpty()) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(
+ modifier = Modifier.padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.nfccommands_log_empty_title),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ Text(
+ text = stringResource(R.string.nfccommands_log_empty_body),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(AapsSpacing.medium),
+ verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium)
+ ) {
+ items(entries) { entry ->
+ NfcLogEntryCard(entry)
+ }
+ }
+ }
+}
+
+@Composable
+private fun NfcLogEntryCard(entry: NfcLogEntry) {
+ val dateFormatter = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) }
+ val color = if (entry.success) Color(0xFF4CAF50) else Color(0xFFF44336)
+ val actionLabel = when (entry.action) {
+ "READ" -> stringResource(R.string.nfccommands_log_action_read)
+ "WRITE" -> stringResource(R.string.nfccommands_log_action_write)
+ "MANUAL" -> stringResource(R.string.nfccommands_log_action_manual)
+ else -> entry.action
+ }
+
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.elevatedCardElevation(defaultElevation = AapsSpacing.extraSmall)
+ ) {
+ Column(modifier = Modifier.padding(AapsSpacing.large)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(text = entry.tagName, style = MaterialTheme.typography.titleSmall)
+ Text(text = dateFormatter.format(entry.timestamp), style = MaterialTheme.typography.bodySmall)
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = 4.dp),
+ ) {
+ Surface(
+ color = color.copy(alpha = 0.1f),
+ shape = MaterialTheme.shapes.extraSmall,
+ ) {
+ Text(
+ text = actionLabel,
+ color = color,
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = entry.message, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+}
+
+@Composable
+private fun NfcTagsScreen(
+ plugin: NfcCommandsPlugin,
+ nfcTagStore: NfcTagStore,
+ onBuild: () -> Unit,
+ onEdit: (NfcCreatedTag) -> Unit,
+) {
+ val coroutineScope = rememberCoroutineScope()
+ var refreshKey by remember { mutableIntStateOf(0) }
+ var tags by remember { mutableStateOf>(emptyList()) }
+ var deleteTarget by remember { mutableStateOf(null) }
+ var executeTarget by remember { mutableStateOf(null) }
+
+ LaunchedEffect(refreshKey) {
+ tags = nfcTagStore.loadCreatedTags()
+ }
+
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) refreshKey++
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ deleteTarget?.let { tag ->
+ AlertDialog(
+ onDismissRequest = { deleteTarget = null },
+ title = { Text(stringResource(R.string.nfccommands_delete_confirm_title)) },
+ text = { Text(stringResource(R.string.nfccommands_delete_confirm_msg)) },
+ confirmButton = {
+ TextButton(onClick = {
+ nfcTagStore.deleteCreatedTag(tag.tagUid)
+ refreshKey++
+ deleteTarget = null
+ }) { Text(stringResource(CoreUiR.string.delete)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { deleteTarget = null }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+ }
+
+ executeTarget?.let { tag ->
+ NfcExecutionConfirmationDialog(
+ tag = tag,
+ plugin = plugin,
+ onConfirm = {
+ val commands = tag.commands
+ val tagName = tag.name
+ executeTarget = null
+ coroutineScope.launch {
+ withContext(Dispatchers.IO) { plugin.executeWithFeedback(commands, tagName, action = "MANUAL") }
+ refreshKey++
+ }
+ },
+ onDismiss = { executeTarget = null }
+ )
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (tags.isEmpty()) {
+ Column(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(R.string.nfccommands_empty_state_title),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ Text(
+ text = stringResource(R.string.nfccommands_empty_state_body),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(AapsSpacing.medium),
+ verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium)
+ ) {
+ items(tags, key = { it.tagUid + it.createdAtMillis }) { tag ->
+ NfcTagCard(
+ plugin = plugin,
+ tag = tag,
+ onExecute = { executeTarget = tag },
+ onEdit = { onEdit(tag) },
+ onDelete = { deleteTarget = tag },
+ )
+ }
+ }
+ }
+ AapsFab(
+ onClick = onBuild,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(AapsSpacing.extraLarge),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Add,
+ contentDescription = stringResource(R.string.nfccommands_add_tag),
+ )
+ }
+ }
+}
+
+@Composable
+private fun NfcTagCard(
+ plugin: NfcCommandsPlugin,
+ tag: NfcCreatedTag,
+ onExecute: () -> Unit,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ val dateFormatter = remember { DateFormat.getDateInstance(DateFormat.SHORT) }
+
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.elevatedCardElevation(defaultElevation = AapsSpacing.extraSmall)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AapsSpacing.large),
+ horizontalArrangement = Arrangement.spacedBy(AapsSpacing.small),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(AapsSpacing.small)
+ ) {
+ Text(
+ text = tag.name,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ if (tag.lastScannedAtMillis != null) {
+ Text(
+ text = dateFormatter.format(tag.lastScannedAtMillis),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ Row(
+ modifier = Modifier.padding(top = AapsSpacing.extraSmall),
+ horizontalArrangement = Arrangement.spacedBy(AapsSpacing.small)
+ ) {
+ tag.commands.forEach { cmdJson ->
+ NfcIconOnlyDisplay(plugin = plugin, commandJson = cmdJson)
+ }
+ }
+ }
+ IconButton(onClick = onExecute) {
+ Icon(Icons.Filled.PlayArrow, contentDescription = stringResource(R.string.nfccommands_execute_tag))
+ }
+ IconButton(onClick = onEdit) {
+ Icon(Icons.Default.Edit, contentDescription = null)
+ }
+ IconButton(onClick = onDelete) {
+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.nfccommands_disable_tag))
+ }
+ }
+ }
+}
+
+@Composable
+private fun NfcIconOnlyDisplay(plugin: NfcCommandsPlugin, commandJson: String) {
+ val json = remember(commandJson) { runCatching { JSONObject(commandJson) }.getOrNull() }
+ val codeName = json?.optString(NfcJsonKeys.CODE)
+ val code = remember(codeName) { if (codeName != null) runCatching { NfcCommandCode.valueOf(codeName) }.getOrNull() else null }
+ val params = remember(json) { json?.optJSONObject(NfcJsonKeys.PARAMS) ?: JSONObject() }
+
+ if (code != null) {
+ val action = remember(code, params) {
+ plugin.getAction(code).apply { this.params = params }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+ Icon(
+ imageVector = action.icon,
+ contentDescription = null,
+ tint = action.customIconColor?.invoke() ?: action.elementType.color(),
+ modifier = Modifier.size(20.dp)
+ )
+ action.secondaryIcon?.let { secondaryIcon ->
+ Icon(
+ imageVector = secondaryIcon,
+ contentDescription = null,
+ tint = action.secondaryIconColor?.invoke() ?: action.elementType.color(),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommonUi.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommonUi.kt
new file mode 100644
index 000000000000..d661b7fc75d6
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommonUi.kt
@@ -0,0 +1,143 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import android.R
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import app.aaps.core.ui.compose.icons.IcPluginNfc
+import app.aaps.core.ui.compose.navigation.color
+import org.json.JSONObject
+import app.aaps.core.ui.R as CoreUiR
+
+@Composable
+fun NfcExecutionConfirmationDialog(
+ tag: NfcCreatedTag,
+ plugin: NfcCommandsPlugin,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ icon = {
+ Icon(
+ imageVector = IcPluginNfc,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(48.dp)
+ )
+ },
+ title = {
+ Text(
+ text = tag.name,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.Start
+ ) {
+ tag.commands.forEach { cmdJson ->
+ NfcCommandDisplay(commandJson = cmdJson, plugin = plugin)
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text(stringResource(CoreUiR.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false)
+ )
+}
+
+@Composable
+fun NfcCommandDisplay(
+ commandJson: String,
+ plugin: NfcCommandsPlugin
+) {
+ val json = remember(commandJson) { runCatching { JSONObject(commandJson) }.getOrNull() }
+ if (json == null) {
+ Text(text = commandJson, style = MaterialTheme.typography.bodySmall)
+ return
+ }
+
+ val codeName = json.optString(NfcJsonKeys.CODE)
+ val code = remember(codeName) { runCatching { NfcCommandCode.valueOf(codeName) }.getOrNull() }
+ val params = json.optJSONObject(NfcJsonKeys.PARAMS) ?: JSONObject()
+
+ if (code == null) {
+ Text(text = commandJson, style = MaterialTheme.typography.bodySmall)
+ return
+ }
+
+ val action = remember(code, params) {
+ plugin.getAction(code).apply { this.params = params }
+ }
+
+ val detail by produceState(initialValue = null, action) {
+ value = action.formatParams()
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 2.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ imageVector = action.icon,
+ contentDescription = null,
+ tint = action.customIconColor?.invoke() ?: action.elementType.color(),
+ modifier = Modifier.size(16.dp)
+ )
+ action.secondaryIcon?.let { secondaryIcon ->
+ Icon(
+ imageVector = secondaryIcon,
+ contentDescription = null,
+ tint = action.secondaryIconColor?.invoke() ?: action.elementType.color(),
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ Text(
+ text = buildAnnotatedString {
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(" ")
+ append(stringResource(action.labelResId))
+ }
+ if (detail != null) {
+ append(" ")
+ append(detail!!)
+ }
+ },
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcControlActivity.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcControlActivity.kt
new file mode 100644
index 000000000000..76dd003108e8
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcControlActivity.kt
@@ -0,0 +1,221 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.fragment.app.FragmentActivity
+import app.aaps.core.interfaces.configuration.Config
+import app.aaps.core.interfaces.logging.AAPSLogger
+import app.aaps.core.interfaces.resources.ResourceHelper
+import app.aaps.core.interfaces.rx.bus.RxBus
+import app.aaps.core.interfaces.utils.DateUtil
+import app.aaps.core.keys.interfaces.Preferences
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.LocalConfig
+import app.aaps.core.ui.compose.LocalDateUtil
+import app.aaps.core.ui.compose.LocalPreferences
+import app.aaps.core.ui.compose.LocalSnackbarHostState
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.ui.Alignment
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import app.aaps.core.interfaces.clientcontrol.ClientControlActionDispatcher
+import app.aaps.core.interfaces.pump.BolusProgressData
+import app.aaps.core.interfaces.queue.CommandQueue
+import app.aaps.core.ui.compose.dialogs.GlobalSnackbarHost
+import app.aaps.core.ui.compose.pump.PumpActivityDialog
+import app.aaps.core.ui.compose.pump.PumpCommunicationStatus
+import dagger.android.AndroidInjection
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import javax.inject.Inject
+
+open class NfcControlActivity : FragmentActivity() {
+
+ @Inject lateinit var nfcPlugin: NfcCommandsPlugin
+ @Inject lateinit var aapsLogger: AAPSLogger
+ @Inject lateinit var rh: ResourceHelper
+ @Inject lateinit var preferences: Preferences
+ @Inject lateinit var dateUtil: DateUtil
+ @Inject lateinit var config: Config
+ @Inject lateinit var rxBus: RxBus
+ @Inject lateinit var bolusProgressData: BolusProgressData
+ @Inject lateinit var commandQueue: CommandQueue
+ @Inject lateinit var clientControlActionDispatcher: ClientControlActionDispatcher
+
+ private val pumpCommunicationStatus by lazy {
+ PumpCommunicationStatus(rxBus, commandQueue, this, lifecycleScope)
+ }
+
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var pendingTag by mutableStateOf(null)
+ private var awaitingBolusFinish by mutableStateOf(false)
+
+ companion object {
+ private const val BOLUS_START_GRACE_MS = 500L
+ private const val BOLUS_START_POLL_MS = 20L
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ AndroidInjection.inject(this)
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ val snackbarHostState = remember { SnackbarHostState() }
+ CompositionLocalProvider(
+ LocalPreferences provides preferences,
+ LocalDateUtil provides dateUtil,
+ LocalConfig provides config,
+ LocalSnackbarHostState provides snackbarHostState
+ ) {
+ AapsTheme {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = Color.Transparent
+ ) {
+ val currentTag = pendingTag
+ if (currentTag != null) {
+ NfcExecutionConfirmationDialog(
+ tag = currentTag,
+ plugin = nfcPlugin,
+ onConfirm = {
+ pendingTag = null
+ scope.launch {
+ nfcPlugin.updateLastScanned(currentTag.tagUid)
+ nfcPlugin.executeWithFeedback(currentTag.commands, currentTag.name)
+ withContext(Dispatchers.Main) {
+ if (hasBolusCommand(currentTag.commands)) {
+ var waited = 0L
+ while (bolusProgressData.state.value == null && waited < BOLUS_START_GRACE_MS) {
+ delay(BOLUS_START_POLL_MS)
+ waited += BOLUS_START_POLL_MS
+ }
+ if (bolusProgressData.state.value != null) {
+ awaitingBolusFinish = true
+ } else {
+ finishWithTransition()
+ }
+ } else {
+ finishWithTransition()
+ }
+ }
+ }
+ },
+ onDismiss = {
+ pendingTag = null
+ finish()
+ }
+ )
+ }
+
+ val bolusState by bolusProgressData.state.collectAsStateWithLifecycle()
+
+ LaunchedEffect(bolusState, awaitingBolusFinish) {
+ if (awaitingBolusFinish && bolusState == null) finishWithTransition()
+ }
+
+ bolusState?.let { state ->
+ if (!state.isSMB) {
+ val pumpStatus by pumpCommunicationStatus.statusBannerFlow.collectAsStateWithLifecycle()
+ val queueStatus by pumpCommunicationStatus.queueStatusFlow.collectAsStateWithLifecycle()
+ PumpActivityDialog(
+ bolusState = state,
+ pumpStatus = pumpStatus?.text ?: "",
+ queueStatus = queueStatus ?: "",
+ isModal = true,
+ onStop = {
+ if (config.AAPSCLIENT) {
+ clientControlActionDispatcher.stopBolus()
+ bolusProgressData.stopPressed()
+ } else {
+ commandQueue.cancelAllBoluses(null)
+ }
+ },
+ onDismiss = { bolusProgressData.clear() }
+ )
+ }
+ }
+ }
+ GlobalSnackbarHost(
+ rxBus = rxBus,
+ hostState = snackbarHostState,
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ }
+ }
+ }
+ }
+
+ handleIntent(intent)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ handleIntent(intent)
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun handleIntent(intent: Intent?) {
+ scope.launch {
+ when (val prep = nfcPlugin.processIntent(intent)) {
+ is NfcPrepareResult.Error -> {
+ if (prep.message.isNotEmpty()) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(this@NfcControlActivity, prep.message, Toast.LENGTH_LONG).show()
+ }
+ }
+ if (pendingTag == null) finish()
+ }
+ is NfcPrepareResult.Ready -> {
+ withContext(Dispatchers.Main) {
+ pendingTag = NfcCreatedTag(
+ tagUid = prep.tagUid,
+ name = prep.tagName,
+ commands = prep.commands,
+ createdAtMillis = 0L
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun finishWithTransition() {
+ packageManager.getLaunchIntentForPackage(packageName)?.apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }?.let { startActivity(it) }
+ finish()
+ }
+
+ private fun hasBolusCommand(commands: List): Boolean =
+ commands.any { cmd ->
+ val code = runCatching {
+ NfcCommandCode.valueOf(JSONObject(cmd).optString(NfcJsonKeys.CODE))
+ }.getOrNull()
+ code == NfcCommandCode.BOLUS || code == NfcCommandCode.BOLUS_WIZARD
+ }
+}
\ No newline at end of file
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcForegroundDispatch.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcForegroundDispatch.kt
new file mode 100644
index 000000000000..5a3138ad3bf1
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcForegroundDispatch.kt
@@ -0,0 +1,80 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Intent
+import android.nfc.NfcAdapter
+import android.nfc.NfcManager
+import android.os.Build
+import app.aaps.core.interfaces.resources.ResourceHelper
+import app.aaps.core.interfaces.rx.bus.RxBus
+import app.aaps.core.interfaces.rx.events.EventShowDialog
+import app.aaps.core.keys.BooleanKey
+import app.aaps.plugins.sync.R
+import app.aaps.core.keys.interfaces.Preferences
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
+
+/**
+ * Manages NFC foreground dispatch for [activity]. While the activity is resumed and
+ * [BooleanKey.NfcForegroundPriority] is enabled, all NFC tags are routed to AAPS
+ * ahead of other apps (e.g. LibreLink). Tags are forwarded to [NfcControlActivity].
+ *
+ * Lifecycle: call [onResume], [onPause], and [onNewIntent] from the host activity.
+ * Call [observeWarning] once during setup to show a dialog when the setting is enabled.
+ */
+class NfcForegroundDispatch(
+ private val activity: Activity,
+ private val preferences: Preferences,
+) {
+ private val adapter: NfcAdapter? by lazy {
+ (activity.getSystemService(NfcManager::class.java))?.defaultAdapter
+ }
+ private var enabled = false
+
+ fun onResume() {
+ if (!preferences.get(BooleanKey.NfcForegroundPriority)) return
+ val adapter = adapter ?: return
+ val pendingIntent = PendingIntent.getActivity(
+ activity, 0,
+ Intent(activity, activity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
+ )
+ adapter.enableForegroundDispatch(activity, pendingIntent, null, null)
+ enabled = true
+ }
+
+ fun onPause() {
+ if (!enabled) return
+ adapter?.disableForegroundDispatch(activity)
+ enabled = false
+ }
+
+ fun onNewIntent(intent: Intent) {
+ val action = intent.action ?: return
+ if (action != NfcAdapter.ACTION_NDEF_DISCOVERED &&
+ action != NfcAdapter.ACTION_TECH_DISCOVERED &&
+ action != NfcAdapter.ACTION_TAG_DISCOVERED
+ ) return
+ activity.startActivity(Intent(activity, NfcControlActivity::class.java).apply {
+ this.action = action
+ intent.extras?.let { putExtras(it) }
+ })
+ }
+
+ fun observeWarning(scope: CoroutineScope, rxBus: RxBus, rh: ResourceHelper) {
+ scope.launch {
+ preferences.observe(BooleanKey.NfcForegroundPriority).drop(1).collect { enabled ->
+ if (enabled) {
+ rxBus.send(
+ EventShowDialog.Ok(
+ title = rh.gs(R.string.nfc_foreground_priority_warning_title),
+ message = rh.gs(R.string.nfc_foreground_priority_warning_message),
+ )
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcJsonKeys.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcJsonKeys.kt
new file mode 100644
index 000000000000..6d37179a21ab
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcJsonKeys.kt
@@ -0,0 +1,28 @@
+package app.aaps.plugins.sync.nfcCommands
+
+
+/**
+ * Central registry for all JSON keys used in NFC command serialization.
+ * Using constants ensures consistency between UI state management and action execution.
+ */
+object NfcJsonKeys {
+ const val CODE = "code"
+ const val PARAMS = "params"
+ const val TAG_NAME = "tagname"
+
+ const val AMOUNT = "amount" // Used for Insulin (Units) and Carbs (Grams)
+ const val GLUCOSE = "glucose" // Used for Temp Targets
+ const val PERCENT = "percent" // Used for Profile Switch, Wizard, and Basal PCT
+ const val DURATION = "duration" // Used for TT, Basal, and Suspend (Minutes)
+ const val RATE = "rate" // Used for Absolute Basal (U/h)
+ const val PROFILE_NAME = "profileName"
+ const val SCENE_ID = "sceneId"
+ const val IS_MEAL = "isMeal" // Bolus checkbox
+
+ // Bolus Wizard specific options
+ const val USE_BG = "useBg"
+ const val USE_TT = "useTT"
+ const val USE_TREND = "useTrend"
+ const val USE_IOB = "useIOB"
+ const val USE_COB = "useCOB"
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcTagStore.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcTagStore.kt
new file mode 100644
index 000000000000..c4e377de35ff
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/NfcTagStore.kt
@@ -0,0 +1,172 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import app.aaps.core.interfaces.sharedPreferences.SP
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import org.json.JSONArray
+import org.json.JSONObject
+import javax.inject.Inject
+import javax.inject.Singleton
+
+data class NfcCreatedTag(
+ val tagUid: String,
+ val name: String,
+ val commands: List,
+ val createdAtMillis: Long,
+ val lastScannedAtMillis: Long? = null,
+)
+
+data class NfcLogEntry(
+ val timestamp: Long,
+ val tagName: String,
+ val action: String,
+ val success: Boolean,
+ val message: String,
+)
+
+@Singleton
+class NfcTagStore @Inject constructor(private val sp: SP) {
+
+ companion object {
+ const val MIME_TYPE: String = "application/vnd.app.aaps.command"
+ private const val PREFS_TAGS = "nfccommunicator_created_tags_v1"
+ private const val PREFS_LOG = "nfccommunicator_log_v1"
+ private const val LOG_MAX_ENTRIES = 100
+
+ fun buildCommand(
+ code: NfcCommandCode,
+ params: JSONObject = JSONObject(),
+ ): String {
+ return JSONObject()
+ .put(NfcJsonKeys.CODE, code.name)
+ .put(NfcJsonKeys.PARAMS, params)
+ .toString()
+ }
+
+ fun tagUidHex(id: ByteArray?): String? = id?.joinToString("") { "%02x".format(it) }
+ }
+
+ private val _logUpdates = MutableSharedFlow(extraBufferCapacity = 1)
+ val logUpdates: Flow = _logUpdates
+
+ // uid (lowercase) → System.currentTimeMillis() at write time; cleared implicitly by expiry
+ private val recentlyWrittenUids = mutableMapOf()
+
+ fun markJustWritten(uid: String) {
+ recentlyWrittenUids[uid.lowercase()] = System.currentTimeMillis()
+ }
+
+ fun isJustWritten(uid: String, cooldownMs: Long = 5_000L): Boolean {
+ val writtenAt = recentlyWrittenUids[uid.lowercase()] ?: return false
+ return System.currentTimeMillis() - writtenAt < cooldownMs
+ }
+
+ internal fun clearJustWrittenForTest() {
+ recentlyWrittenUids.clear()
+ }
+
+ fun findTagByUid(uid: String): NfcCreatedTag? =
+ loadCreatedTags().find { it.tagUid.equals(uid, ignoreCase = true) }
+
+ fun loadCreatedTags(): List {
+ val raw = sp.getString(PREFS_TAGS, "[]")
+ val tags = mutableListOf()
+ val array = runCatching { JSONArray(raw) }.getOrElse { JSONArray() }
+ for (index in 0 until array.length()) {
+ val item = array.optJSONObject(index) ?: continue
+ val commandsJson = item.optJSONArray("commands") ?: JSONArray()
+ val commands = (0 until commandsJson.length())
+ .asSequence()
+ .map { commandsJson.optString(it) }
+ .filter { it.isNotBlank() }
+ .toList()
+ if (commands.isEmpty()) continue
+ val tagUid = item.optString("tagUid")
+ if (tagUid.isBlank()) continue
+ tags.add(
+ NfcCreatedTag(
+ tagUid = tagUid,
+ name = item.optString("name"),
+ commands = commands,
+ createdAtMillis = item.optLong("createdAtMillis"),
+ lastScannedAtMillis = item.optLong("lastScannedAtMillis", 0L).takeIf { it > 0 },
+ ),
+ )
+ }
+ return tags.sortedByDescending { it.createdAtMillis }
+ }
+
+ fun saveCreatedTag(tag: NfcCreatedTag) {
+ val updated = loadCreatedTags().filterNot { it.tagUid.equals(tag.tagUid, ignoreCase = true) }.toMutableList()
+ updated.add(0, tag)
+ saveCreatedTagList(updated)
+ }
+
+ fun deleteCreatedTag(tagUid: String) {
+ val updated = loadCreatedTags().filterNot { it.tagUid.equals(tagUid, ignoreCase = true) }
+ saveCreatedTagList(updated)
+ }
+
+ fun updateLastScanned(tagUid: String, millis: Long = System.currentTimeMillis()) {
+ val tag = findTagByUid(tagUid) ?: return
+ saveCreatedTag(tag.copy(lastScannedAtMillis = millis))
+ }
+
+ private fun saveCreatedTagList(tags: List) {
+ val array = JSONArray()
+ tags.forEach { current ->
+ val cmdsArray = JSONArray()
+ current.commands.forEach { cmdsArray.put(it) }
+ val obj = JSONObject()
+ .put("tagUid", current.tagUid)
+ .put("name", current.name)
+ .put("commands", cmdsArray)
+ .put("createdAtMillis", current.createdAtMillis)
+ current.lastScannedAtMillis?.let { obj.put("lastScannedAtMillis", it) }
+ array.put(obj)
+ }
+ sp.edit { putString(PREFS_TAGS, array.toString()) }
+ }
+
+ fun appendLogEntry(entry: NfcLogEntry) {
+ val existing = loadLog().toMutableList()
+ existing.add(0, entry)
+ val pruned = existing.take(LOG_MAX_ENTRIES)
+ val array = JSONArray()
+ pruned.forEach { e ->
+ array.put(
+ JSONObject()
+ .put("timestamp", e.timestamp)
+ .put("tagName", e.tagName)
+ .put("action", e.action)
+ .put("success", e.success)
+ .put("message", e.message),
+ )
+ }
+ sp.edit { putString(PREFS_LOG, array.toString()) }
+ _logUpdates.tryEmit(Unit)
+ }
+
+ fun loadLog(): List =
+ try {
+ val array = JSONArray(sp.getString(PREFS_LOG, "[]"))
+ List(array.length()) { i ->
+ val o = array.getJSONObject(i)
+ NfcLogEntry(
+ timestamp = o.getLong("timestamp"),
+ tagName = o.getString("tagName"),
+ action = o.getString("action"),
+ success = o.getBoolean("success"),
+ message = o.getString("message"),
+ )
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+
+ fun clearLog() {
+ sp.edit { remove(PREFS_LOG) }
+ _logUpdates.tryEmit(Unit)
+ }
+
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/AapsClientRestartAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/AapsClientRestartAction.kt
new file mode 100644
index 000000000000..5661a86d473f
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/AapsClientRestartAction.kt
@@ -0,0 +1,31 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.ue.Action
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.interfaces.sync.NsClient
+import app.aaps.core.ui.compose.icons.IcAaps
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+
+class AapsClientRestartAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_aapsclient_restart
+ override val elementType = ElementType.AAPS
+ override val argType = listOf()
+ override val icon = IcAaps
+
+ override suspend fun execute(): NfcExecutionResult {
+ plugin.activePlugin.getSpecificPluginsListByInterface(NsClient::class.java).forEach {
+ (it as? NsClient)?.resend("NFC")
+ }
+ uel.log(
+ action = Action.START_AAPS,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ source = source,
+ )
+ return NfcExecutionResult(true, plugin.rh.gs(R.string.nfccommands_aapsclient_restart_sent))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/BasalCancelAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/BasalCancelAction.kt
new file mode 100644
index 000000000000..e858ac079981
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/BasalCancelAction.kt
@@ -0,0 +1,34 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.ue.Action
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.icons.IcTbrCancel
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.core.ui.R as CoreUiR
+
+class BasalCancelAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.cancel_temp
+ override val elementType = ElementType.TEMP_BASAL
+ override val argType = listOf()
+ override val icon = IcTbrCancel
+
+ override suspend fun execute(): NfcExecutionResult {
+ val result = plugin.commandQueue.cancelTempBasal(enforceNew = true)
+ return if (result.success) {
+ uel.log(
+ action = Action.CANCEL_TEMP_BASAL,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ source = source,
+ )
+ NfcExecutionResult(true, plugin.rh.gs(CoreUiR.string.stoptemptarget))
+ } else {
+ plugin.aapsLogger.error(LTag.NFC, "cancelTempBasal failed: ${result.comment}")
+ commandNotPossible()
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/BolusAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/BolusAction.kt
new file mode 100644
index 000000000000..44733f3048c9
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/BolusAction.kt
@@ -0,0 +1,128 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import app.aaps.core.data.configuration.Constants
+import app.aaps.core.data.model.TT
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.Sources
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.interfaces.pump.DetailedBolusInfo
+import app.aaps.core.interfaces.tempTargets.ttDurationMinutes
+import app.aaps.core.interfaces.tempTargets.ttTargetMgdl
+import app.aaps.core.objects.constraints.ConstraintObject
+import app.aaps.core.ui.compose.icons.IcTtEatingSoon
+import app.aaps.core.ui.compose.navigation.color
+import app.aaps.core.ui.compose.navigation.icon
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit
+import app.aaps.core.ui.R as CoreUiR
+
+class BolusAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.bolus
+ override val elementType = ElementType.INSULIN
+ override val argType = listOf(ArgType.INSULIN, ArgType.MEAL_CHECK)
+ override val icon
+ get() = elementType.icon()
+
+ override val secondaryIcon: ImageVector?
+ get() = if (params.optBoolean(NfcJsonKeys.IS_MEAL, false)) IcTtEatingSoon else null
+
+ override val secondaryIconColor: (@Composable () -> Color)?
+ get() = if (params.optBoolean(NfcJsonKeys.IS_MEAL, false)) {
+ @Composable { ElementType.TEMP_TARGET_MANAGEMENT.color() }
+ } else null
+
+ override suspend fun getDefaultParams(): JSONObject =
+ JSONObject().put(NfcJsonKeys.AMOUNT, 0.0).put(NfcJsonKeys.IS_MEAL, false)
+
+ override suspend fun formatParams(): String {
+ val amount = params.optDouble(NfcJsonKeys.AMOUNT, 0.0)
+ val isMeal = params.optBoolean(NfcJsonKeys.IS_MEAL, false)
+ val base = plugin.rh.gs(CoreUiR.string.goingtodeliver, amount)
+ return if (isMeal) {
+ "$base (${plugin.rh.gs(CoreUiR.string.eatingsoon)})"
+ } else {
+ base
+ }
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ if (plugin.commandQueue.bolusInQueue()) {
+ return NfcExecutionResult(false, plugin.rh.gs(R.string.nfccommands_another_bolus_in_queue))
+ }
+ if (plugin.dateUtil.now() - plugin.lastRemoteBolusTime < Constants.remoteBolusMinDistance) {
+ return NfcExecutionResult(false, plugin.rh.gs(R.string.nfccommands_remote_bolus_not_allowed))
+ }
+ if (plugin.loop.runningMode().pausesLoopExecution()) {
+ return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.pumpsuspended))
+ }
+
+ var bolus = params.optDouble(NfcJsonKeys.AMOUNT, 0.0)
+ val isMeal = params.optBoolean(NfcJsonKeys.IS_MEAL, false)
+
+ if (bolus <= 0.0) return invalidFormat()
+
+ bolus = plugin.constraintChecker.applyBolusConstraints(ConstraintObject(bolus, plugin.aapsLogger)).value()
+
+ val detailedBolusInfo = DetailedBolusInfo().apply { insulin = bolus }
+ val result = plugin.commandQueue.bolus(detailedBolusInfo)
+
+ val userStop = plugin.bolusProgressData.isStopPressed
+ if (!result.success && !userStop) {
+ plugin.aapsLogger.error(LTag.NFC, "bolus failed: ${result.comment}")
+ return commandNotPossible()
+ }
+
+ val delivered = result.bolusDelivered
+ uel.log(
+ action = if (isMeal) Action.TREATMENT else Action.BOLUS,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.Insulin(delivered)
+ )
+ )
+
+ plugin.setLastRemoteBolusTime(plugin.dateUtil.now())
+
+ if (isMeal && !userStop) {
+ plugin.profileFunction.getProfile()?.let {
+ val eatingSoonTTDuration = plugin.preferences.ttDurationMinutes(TT.Reason.EATING_SOON)
+ val eatingSoonTT = plugin.profileUtil.fromMgdlToUnits(plugin.preferences.ttTargetMgdl(TT.Reason.EATING_SOON), plugin.profileUtil.units)
+ plugin.persistenceLayer.insertAndCancelCurrentTemporaryTarget(
+ temporaryTarget = TT(
+ timestamp = plugin.dateUtil.now(),
+ duration = TimeUnit.MINUTES.toMillis(eatingSoonTTDuration.toLong()),
+ reason = TT.Reason.EATING_SOON,
+ lowTarget = plugin.profileUtil.convertToMgdl(eatingSoonTT, plugin.profileUtil.units),
+ highTarget = plugin.profileUtil.convertToMgdl(eatingSoonTT, plugin.profileUtil.units),
+ ),
+ action = Action.TT,
+ source = Sources.NfcCommands,
+ note = null,
+ listValues = listOf(
+ ValueWithUnit.TETTReason(TT.Reason.EATING_SOON),
+ ValueWithUnit.Mgdl(plugin.profileUtil.convertToMgdl(eatingSoonTT, plugin.profileUtil.units)),
+ ValueWithUnit.Minute(eatingSoonTTDuration),
+ ),
+ )
+ }
+ }
+
+ val resId = if (userStop) CoreUiR.string.stop_pressed
+ else if (isMeal) R.string.smscommunicator_meal_bolus_delivered
+ else R.string.smscommunicator_bolus_delivered
+
+ return NfcExecutionResult(true, plugin.rh.gs(resId, delivered))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/BolusWizardAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/BolusWizardAction.kt
new file mode 100644
index 000000000000..c7b1f4bd4d83
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/BolusWizardAction.kt
@@ -0,0 +1,135 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.configuration.Constants
+import app.aaps.core.data.time.T
+import app.aaps.core.interfaces.bolus.WizardBolusExecutor
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.keys.BooleanKey
+import app.aaps.core.keys.BooleanNonKey
+import app.aaps.core.keys.IntKey
+import app.aaps.core.ui.compose.navigation.icon
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import org.json.JSONObject
+import app.aaps.core.ui.R as CoreUiR
+
+class BolusWizardAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.boluswizard
+ override val elementType = ElementType.BOLUS_WIZARD
+ override val argType = listOf(ArgType.BOLUS_WIZARD_OPTIONS, ArgType.AMOUNT_GRAMS, ArgType.PERCENT)
+ override val icon
+ get() = elementType.icon()
+
+ override suspend fun getDefaultParams(): JSONObject {
+ val useTrend = plugin.preferences.get(BooleanNonKey.WizardIncludeTrend)
+ val useCOB = plugin.preferences.get(BooleanNonKey.WizardIncludeCob)
+ var percentage = plugin.preferences.get(IntKey.OverviewBolusPercentage)
+ val time = plugin.preferences.get(IntKey.OverviewResetBolusPercentageTime).toLong()
+ plugin.persistenceLayer.getLastGlucoseValue().let {
+ if (it != null) {
+ if (it.timestamp < plugin.dateUtil.now() - T.mins(time).msecs())
+ percentage = 100
+ } else percentage = 100
+ }
+ return JSONObject().apply {
+ put(NfcJsonKeys.AMOUNT, 0)
+ put(NfcJsonKeys.PERCENT, percentage)
+ put(NfcJsonKeys.USE_BG, true)
+ put(NfcJsonKeys.USE_TT, true)
+ put(NfcJsonKeys.USE_TREND, useTrend)
+ put(NfcJsonKeys.USE_IOB, true)
+ put(NfcJsonKeys.USE_COB, useCOB)
+ }
+ }
+
+ override suspend fun formatParams(): String? {
+ val amount = params.optInt(NfcJsonKeys.AMOUNT, 0)
+ return when (val prepared = prepareWizard()) {
+ is WizardBolusExecutor.PrepareResult.Preview -> {
+ // Park the SAME preview (bolusId + computed insulin) the confirm dialog just displayed —
+ // execute() commits it by id through the shared WizardBolusExecutor (identical to wear /
+ // client-control), instead of re-driving a shared/leftover BolusWizard instance.
+ plugin.setActionState(params.toString(), prepared)
+ val base = plugin.rh.gs(CoreUiR.string.goingtodeliver, prepared.insulin)
+ val carbs = plugin.rh.gs(CoreUiR.string.format_carbs, amount)
+ "$base ($carbs)"
+ }
+ is WizardBolusExecutor.PrepareResult.Error -> prepared.message
+ WizardBolusExecutor.PrepareResult.NoAction -> null
+ }
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ if (plugin.commandQueue.bolusInQueue()) {
+ return NfcExecutionResult(false, plugin.rh.gs(R.string.nfccommands_another_bolus_in_queue))
+ }
+ if (plugin.dateUtil.now() - plugin.lastRemoteBolusTime < Constants.remoteBolusMinDistance) {
+ return NfcExecutionResult(false, plugin.rh.gs(R.string.nfccommands_remote_bolus_not_allowed))
+ }
+ if (plugin.loop.runningMode().pausesLoopExecution()) {
+ return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.pumpsuspended))
+ }
+
+ val prepared = plugin.getActionState(params.toString()) as? WizardBolusExecutor.PrepareResult.Preview
+ if (prepared == null) {
+ plugin.aapsLogger.debug(LTag.NFC, "BolusWizard state not found. Key: ${params}")
+ return commandNotPossible()
+ }
+
+ // Commit the parked dose by id — the SAME consume-once relay wear/client-control use
+ // (WizardBolusExecutor.confirm). A stale/already-consumed id returns NoPending instead of
+ // silently re-delivering or no-op'ing without telling the caller.
+ val result = plugin.wizardBolusExecutor.confirm(
+ bolusId = prepared.bolusId,
+ source = source,
+ onError = { plugin.aapsLogger.error(LTag.NFC, "Calculator bolus failed: $it") }
+ )
+ if (result is WizardBolusExecutor.ConfirmResult.NoPending) {
+ plugin.aapsLogger.debug(LTag.NFC, "BolusWizard confirm: no pending dose for id ${prepared.bolusId}")
+ return commandNotPossible()
+ }
+
+ plugin.setLastRemoteBolusTime(plugin.dateUtil.now())
+ return NfcExecutionResult(true, plugin.rh.gs(R.string.smscommunicator_bolus_delivered, prepared.insulin))
+ }
+
+ /**
+ * Recompute + cap + park the dose through the shared [WizardBolusExecutor] — the SAME "prepare" entry
+ * point wear uses (DataHandlerMobile.handleWizardPreCheck) and client-control. This always builds a
+ * FRESH BolusWizard internally (via Provider), so the wizard's one-shot `accepted` delivery guard can
+ * never leak across NFC scans, unlike the previous design which reused one shared instance forever.
+ */
+ private suspend fun prepareWizard(): WizardBolusExecutor.PrepareResult {
+ val carbs = params.optInt(NfcJsonKeys.AMOUNT, 0)
+ val percentage = params.optInt(NfcJsonKeys.PERCENT, 100)
+ val useBg = params.optBoolean(NfcJsonKeys.USE_BG, true)
+ val useTT = params.optBoolean(NfcJsonKeys.USE_TT, true)
+ val useTrend = params.optBoolean(NfcJsonKeys.USE_TREND, true)
+ val useIOB = params.optBoolean(NfcJsonKeys.USE_IOB, true)
+ val useCOB = params.optBoolean(NfcJsonKeys.USE_COB, true)
+ val bgMgdl = plugin.glucoseStatusProvider.glucoseStatusData?.glucose ?: 0.0
+
+ return plugin.wizardBolusExecutor.prepareWizard(
+ WizardBolusExecutor.WizardInputs(
+ bg = plugin.profileUtil.fromMgdlToUnits(bgMgdl),
+ carbs = carbs,
+ percentage = percentage,
+ directCorrection = 0.0,
+ carbTime = 0,
+ useBg = useBg,
+ useCob = useCOB,
+ useIob = useIOB,
+ useTt = useTT,
+ useTrend = useTrend,
+ alarm = false,
+ notes = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ source = source
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/CarbsAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/CarbsAction.kt
new file mode 100644
index 000000000000..740c4b770bdf
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/CarbsAction.kt
@@ -0,0 +1,60 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.interfaces.pump.DetailedBolusInfo
+import app.aaps.core.objects.constraints.ConstraintObject
+import app.aaps.core.ui.compose.navigation.icon
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import org.json.JSONObject
+import app.aaps.core.ui.R as CoreUiR
+
+class CarbsAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.carbs
+ override val elementType = ElementType.CARBS
+ override val argType = listOf(ArgType.AMOUNT_GRAMS)
+ override val icon
+ get() = elementType.icon()
+
+ override suspend fun getDefaultParams(): JSONObject =
+ JSONObject().put(NfcJsonKeys.AMOUNT, 0)
+
+ override suspend fun formatParams(): String {
+ val grams = params.optInt(NfcJsonKeys.AMOUNT, 0)
+ return plugin.rh.gs(CoreUiR.string.format_carbs, grams)
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ var grams = params.optInt(NfcJsonKeys.AMOUNT, 0)
+
+ if (grams == 0) return invalidFormat()
+
+ grams = plugin.constraintChecker.applyCarbsConstraints(ConstraintObject(grams, plugin.aapsLogger)).value()
+
+ val detailedBolusInfo = DetailedBolusInfo().apply {
+ carbs = grams.toDouble()
+ timestamp = plugin.dateUtil.now()
+ }
+ val result = plugin.commandQueue.bolus(detailedBolusInfo)
+ if (result.success) {
+ uel.log(
+ action = Action.CARBS,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.Gram(grams)
+ )
+ )
+ return NfcExecutionResult(true, plugin.rh.gs(CoreUiR.string.format_carbs, grams))
+ } else {
+ plugin.aapsLogger.error(LTag.NFC, "carbs bolus failed: ${result.comment}")
+ return commandNotPossible()
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/ExtendedCancelAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/ExtendedCancelAction.kt
new file mode 100644
index 000000000000..108cdc99b156
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/ExtendedCancelAction.kt
@@ -0,0 +1,34 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.ue.Action
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.icons.IcCancelExtendedBolus
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+
+class ExtendedCancelAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_extended_stop
+ override val elementType = ElementType.EXTENDED_BOLUS
+ override val argType = listOf()
+ override val icon = IcCancelExtendedBolus
+
+ override suspend fun execute(): NfcExecutionResult {
+ val result = plugin.commandQueue.cancelExtended()
+ return if (result.success) {
+ uel.log(
+ action = Action.CANCEL_EXTENDED_BOLUS,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ )
+ NfcExecutionResult(true, plugin.rh.gs(R.string.nfccommands_extended_canceled))
+ } else {
+ plugin.aapsLogger.error(LTag.NFC, "cancelExtended failed: ${result.comment}")
+ commandNotPossible()
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/ExtendedSetAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/ExtendedSetAction.kt
new file mode 100644
index 000000000000..6c33d852dd17
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/ExtendedSetAction.kt
@@ -0,0 +1,54 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.objects.constraints.ConstraintObject
+import app.aaps.core.ui.compose.navigation.icon
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import org.json.JSONObject
+import app.aaps.core.ui.R as CoreUiR
+
+class ExtendedSetAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.extended_bolus
+ override val elementType = ElementType.EXTENDED_BOLUS
+ override val argType = listOf(ArgType.INSULIN, ArgType.DURATION)
+ override val icon
+ get() = elementType.icon()
+
+ override suspend fun getDefaultParams(): JSONObject =
+ JSONObject().put(NfcJsonKeys.AMOUNT, 0.0).put(NfcJsonKeys.DURATION, 30)
+
+ override suspend fun execute(): NfcExecutionResult {
+ var amount = params.optDouble(NfcJsonKeys.AMOUNT, 0.0)
+ val duration = params.optInt(NfcJsonKeys.DURATION, 0)
+
+ if (amount <= 0.0 || duration <= 0) return invalidFormat()
+
+ amount = plugin.constraintChecker.applyExtendedBolusConstraints(ConstraintObject(amount, plugin.aapsLogger)).value()
+
+ val result = plugin.commandQueue.extendedBolus(amount, duration)
+ if (result.success) {
+ uel.log(
+ action = Action.EXTENDED_BOLUS,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.Insulin(amount),
+ ValueWithUnit.Minute(duration)
+ )
+ )
+ val amountString = plugin.decimalFormatter.to2Decimal(amount)
+ return NfcExecutionResult(true, plugin.rh.gs(R.string.nfccommands_extended_set, amountString, duration))
+ } else {
+ plugin.aapsLogger.error(LTag.NFC, "extendedBolus failed: ${result.comment}")
+ return commandNotPossible()
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopClosedAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopClosedAction.kt
new file mode 100644
index 000000000000..7a6547a4ea74
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopClosedAction.kt
@@ -0,0 +1,54 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.RM
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcLoopClosed
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.core.ui.R as CoreUiR
+
+class LoopClosedAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_loop_close
+ override val elementType = ElementType.LOOP
+ override val argType = listOf()
+ override val icon = IcLoopClosed
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.loopClosed }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val profile = plugin.profileFunction.getProfile() ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.noprofile))
+ if (!plugin.loop.allowedNextModes().contains(RM.Mode.CLOSED_LOOP)) {
+ return commandNotPossible()
+ }
+ val result = plugin.loop.handleRunningModeChange(
+ newRM = RM.Mode.CLOSED_LOOP,
+ action = Action.CLOSED_LOOP_MODE,
+ source = source,
+ profile = profile,
+ )
+ if (result) {
+ uel.log(
+ action = Action.CLOSED_LOOP_MODE,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.RMMode(RM.Mode.CLOSED_LOOP)
+ )
+ )
+ }
+ val message = if (result) {
+ plugin.rh.gs(R.string.nfccommands_current_loop_mode, plugin.rh.gs(CoreUiR.string.closedloop))
+ } else {
+ plugin.rh.gs(R.string.nfccommands_remote_command_not_possible)
+ }
+ return NfcExecutionResult(result, message)
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopLgsAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopLgsAction.kt
new file mode 100644
index 000000000000..f53ae0dc92f9
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopLgsAction.kt
@@ -0,0 +1,54 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.RM
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcLoopLgs
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.core.ui.R as CoreUiR
+
+class LoopLgsAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_loop_lgs
+ override val elementType = ElementType.LOOP
+ override val argType = listOf()
+ override val icon = IcLoopLgs
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.loopLgs }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val profile = plugin.profileFunction.getProfile() ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.noprofile))
+ if (!plugin.loop.allowedNextModes().contains(RM.Mode.CLOSED_LOOP_LGS)) {
+ return commandNotPossible()
+ }
+ val result = plugin.loop.handleRunningModeChange(
+ newRM = RM.Mode.CLOSED_LOOP_LGS,
+ action = Action.LGS_LOOP_MODE,
+ source = source,
+ profile = profile,
+ )
+ if (result) {
+ uel.log(
+ action = Action.LGS_LOOP_MODE,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.RMMode(RM.Mode.CLOSED_LOOP_LGS)
+ )
+ )
+ }
+ val message = if (result) {
+ plugin.rh.gs(R.string.nfccommands_current_loop_mode, plugin.rh.gs(CoreUiR.string.lowglucosesuspend))
+ } else {
+ plugin.rh.gs(R.string.nfccommands_remote_command_not_possible)
+ }
+ return NfcExecutionResult(result, message)
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopResumeAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopResumeAction.kt
new file mode 100644
index 000000000000..f62f00eb7b1c
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopResumeAction.kt
@@ -0,0 +1,50 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.RM
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcLoopClosed
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.core.ui.R as CoreUiR
+
+class LoopResumeAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.resumeloop
+ override val elementType = ElementType.LOOP
+ override val argType = listOf()
+ override val icon = IcLoopClosed
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.loopClosed }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val profile = plugin.profileFunction.getProfile() ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.noprofile))
+ if (!plugin.loop.allowedNextModes().contains(RM.Mode.RESUME) && plugin.loop.runningMode() != RM.Mode.DISABLED_LOOP) {
+ return commandNotPossible()
+ }
+ val result = plugin.loop.handleRunningModeChange(
+ newRM = RM.Mode.RESUME,
+ action = Action.RESUME,
+ source = source,
+ profile = profile,
+ )
+ if (result) {
+ uel.log(
+ action = Action.RESUME,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.RMMode(RM.Mode.RESUME)
+ )
+ )
+ }
+ val messageId = if (result) R.string.nfccommands_loop_resumed else R.string.nfccommands_remote_command_not_possible
+ return NfcExecutionResult(result, plugin.rh.gs(messageId))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopStopAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopStopAction.kt
new file mode 100644
index 000000000000..7e1050838773
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopStopAction.kt
@@ -0,0 +1,51 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.RM
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcLoopDisabled
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.core.ui.R as CoreUiR
+
+class LoopStopAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_loop_stop
+ override val elementType = ElementType.LOOP
+ override val argType = listOf()
+ override val icon = IcLoopDisabled
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.loopDisabled }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val profile = plugin.profileFunction.getProfile() ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.noprofile))
+ if (!plugin.loop.allowedNextModes().contains(RM.Mode.DISABLED_LOOP)) {
+ return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.loopisdisabled))
+ }
+ val result = plugin.loop.handleRunningModeChange(
+ newRM = RM.Mode.DISABLED_LOOP,
+ durationInMinutes = Int.MAX_VALUE,
+ action = Action.LOOP_DISABLED,
+ source = source,
+ profile = profile,
+ )
+ if (result) {
+ uel.log(
+ action = Action.LOOP_DISABLED,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.RMMode(RM.Mode.DISABLED_LOOP)
+ )
+ )
+ }
+ val messageId = if (result) R.string.nfccommands_loop_has_been_disabled else R.string.nfccommands_remote_command_not_possible
+ return NfcExecutionResult(result, plugin.rh.gs(messageId))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopSuspendAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopSuspendAction.kt
new file mode 100644
index 000000000000..a83d46f6bf11
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/LoopSuspendAction.kt
@@ -0,0 +1,68 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.RM
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcLoopPaused
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import org.json.JSONObject
+import app.aaps.core.ui.R as CoreUiR
+
+class LoopSuspendAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.suspendloop
+ override val elementType = ElementType.LOOP
+ override val argType = listOf(ArgType.DURATION)
+ override val icon = IcLoopPaused
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.loopSuspended }
+
+ override suspend fun getDefaultParams(): JSONObject =
+ JSONObject().put(NfcJsonKeys.DURATION, 60)
+
+ override suspend fun formatParams(): String {
+ val duration = params.optInt(NfcJsonKeys.DURATION, 60)
+ return plugin.rh.gs(CoreUiR.string.format_mins, duration)
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val profile = plugin.profileFunction.getProfile() ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.noprofile))
+ val duration = params.optInt(NfcJsonKeys.DURATION, 60)
+ val normalizedDuration = duration.coerceIn(1, 180)
+
+ if (!plugin.loop.allowedNextModes().contains(RM.Mode.SUSPENDED_BY_USER)) {
+ return commandNotPossible()
+ }
+ val result = plugin.loop.handleRunningModeChange(
+ newRM = RM.Mode.SUSPENDED_BY_USER,
+ durationInMinutes = normalizedDuration,
+ action = Action.SUSPEND,
+ source = source,
+ profile = profile,
+ )
+ if (result) {
+ uel.log(
+ action = Action.SUSPEND,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.RMMode(RM.Mode.SUSPENDED_BY_USER),
+ ValueWithUnit.Minute(normalizedDuration)
+ )
+ )
+ }
+ val message = if (result) {
+ plugin.rh.gs(CoreUiR.string.loopsuspended) + " (" + plugin.rh.gs(CoreUiR.string.format_mins, normalizedDuration) + ")"
+ } else {
+ plugin.rh.gs(R.string.nfccommands_remote_command_not_possible)
+ }
+ return NfcExecutionResult(result, message)
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/NfcAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/NfcAction.kt
new file mode 100644
index 000000000000..5a108b9ff652
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/NfcAction.kt
@@ -0,0 +1,92 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import app.aaps.core.data.ue.Sources
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.icons.IcAaps
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcCommandCode
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.nfcCommands.NfcTagStore
+import org.json.JSONObject
+
+/**
+ * Base class for all NFC-triggered actions.
+ * Encapsulates execution logic, UI metadata, and parameter validation.
+ */
+abstract class NfcAction(protected val plugin: NfcCommandsPlugin) {
+
+ protected val uel = plugin.uel
+ protected val source = Sources.NfcCommands
+
+ /** Parameters for this action instance. Uses Compose State to trigger UI updates. */
+ var params: JSONObject by mutableStateOf(JSONObject())
+
+ /** Resource ID for the user-facing label of the action. */
+ @StringRes open val labelResId: Int = 0
+
+ /** UI theme element type for icon coloring. */
+ open val elementType: ElementType = ElementType.AAPS
+
+ /** List of arguments required by this action, used to build the configuration UI. */
+ open val argType = listOf()
+
+ /** Icon displayed in the UI. */
+ open val icon: ImageVector = IcAaps
+
+ /** Optional override for icon color. */
+ open val customIconColor: (@Composable () -> Color)? = null
+
+ /** Secondary icon to display based on current [params]. */
+ open val secondaryIcon: ImageVector? get() = null
+
+ /** Color for the secondary icon. */
+ open val secondaryIconColor: (@Composable () -> Color)? get() = null
+
+ /**
+ * Executes the action using current [params].
+ * @return Result containing success status and a user message.
+ */
+ abstract suspend fun execute(): NfcExecutionResult
+
+ /**
+ * Optional method to format current [params] into a human-readable summary.
+ */
+ open suspend fun formatParams(): String? = null
+
+ /**
+ * Checks if the action is currently supported (e.g., depends on pump capabilities).
+ */
+ open fun isSupported(): Boolean = true
+
+ /**
+ * Provides initial default parameters for the action configuration UI.
+ */
+ open suspend fun getDefaultParams(): JSONObject = JSONObject()
+
+ /**
+ * Serializes this action and its current parameters into a command string for NDEF storage.
+ */
+ fun buildCommand(code: NfcCommandCode, tagName: String): String {
+ val p = JSONObject(params.toString()) // copy
+ p.put(NfcJsonKeys.TAG_NAME, tagName)
+ return NfcTagStore.buildCommand(code, p)
+ }
+
+ /** Helper for reporting invalid parameter formats. */
+ protected fun invalidFormat(): NfcExecutionResult =
+ NfcExecutionResult(false, plugin.rh.gs(R.string.wrong_format))
+
+ /** Helper for reporting that a command cannot be executed in the current state. */
+ protected fun commandNotPossible(): NfcExecutionResult =
+ NfcExecutionResult(false, plugin.rh.gs(R.string.nfccommands_remote_command_not_possible))
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/ProfileSwitchAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/ProfileSwitchAction.kt
new file mode 100644
index 000000000000..3d354f79d294
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/ProfileSwitchAction.kt
@@ -0,0 +1,77 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.navigation.icon
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import org.json.JSONObject
+import app.aaps.core.ui.R as CoreUiR
+
+class ProfileSwitchAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.careportal_profileswitch
+ override val elementType = ElementType.PROFILE_MANAGEMENT
+ override val argType = listOf(ArgType.PROFILE_NAME, ArgType.PERCENT)
+ override val icon
+ get() = elementType.icon()
+ override val customIconColor: @Composable () -> Color = {
+ if (MaterialTheme.colorScheme.surface.luminance() > 0.5f) Color.Black else Color.White
+ }
+
+ override suspend fun getDefaultParams(): JSONObject {
+ val profileName = plugin.profileFunction.getOriginalProfileName()
+ return JSONObject().put(NfcJsonKeys.PROFILE_NAME, profileName).put(NfcJsonKeys.PERCENT, 100)
+ }
+
+ override suspend fun formatParams(): String? {
+ val profileName = params.optString(NfcJsonKeys.PROFILE_NAME)
+ val percentage = params.optInt(NfcJsonKeys.PERCENT, 100)
+ return if (percentage == 100) profileName else "$profileName $percentage%"
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val profileName = params.optString(NfcJsonKeys.PROFILE_NAME)
+ if (profileName.isNullOrBlank()) return invalidFormat()
+ val percentage = params.optInt(NfcJsonKeys.PERCENT, 100).coerceIn(10, 500)
+
+ val profileStore = plugin.profileRepository.profile.value ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.notconfigured))
+
+ val created = plugin.profileFunction.createProfileSwitch(
+ profileStore = profileStore,
+ profileName = profileName,
+ durationInMinutes = 0,
+ percentage = percentage,
+ timeShiftInHours = 0,
+ timestamp = plugin.dateUtil.now(),
+ action = Action.PROFILE_SWITCH,
+ source = source,
+ note = plugin.rh.gs(R.string.nfccommands_profile_switch_created),
+ listValues = listOf(ValueWithUnit.SimpleString(plugin.rh.gsNotLocalised(R.string.nfccommands_profile_switch_created))),
+ iCfg = plugin.insulin.iCfg,
+ )
+ return if (created != null) {
+ val resultMessage = if (percentage == 100) profileName else "$profileName $percentage%"
+ uel.log(
+ action = Action.PROFILE_SWITCH,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.SimpleString(profileName),
+ ValueWithUnit.Percent(percentage)
+ )
+ )
+ NfcExecutionResult(true, resultMessage)
+ } else {
+ NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.invalid_profile))
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/PumpConnectAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/PumpConnectAction.kt
new file mode 100644
index 000000000000..8bdb8afaf8bb
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/PumpConnectAction.kt
@@ -0,0 +1,46 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.RM
+import app.aaps.core.data.ue.Action
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcLoopReconnect
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.core.ui.R as CoreUiR
+
+class PumpConnectAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_pump_connect
+ override val elementType = ElementType.PUMP
+ override val argType = listOf()
+ override val icon = IcLoopReconnect
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.loopClosed }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val profile = plugin.profileFunction.getProfile() ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.noprofile))
+ if (!plugin.loop.allowedNextModes().contains(RM.Mode.RESUME)) {
+ return NfcExecutionResult(true, plugin.rh.gs(app.aaps.core.interfaces.R.string.connected))
+ }
+ val result = plugin.loop.handleRunningModeChange(
+ newRM = RM.Mode.RESUME,
+ action = Action.RECONNECT,
+ source = source,
+ profile = profile,
+ )
+ if (result) {
+ uel.log(
+ action = Action.RECONNECT,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, "")
+ )
+ }
+ val messageId = if (result) R.string.nfccommands_reconnect else R.string.nfccommands_remote_command_not_possible
+ return NfcExecutionResult(result, plugin.rh.gs(messageId))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/PumpDisconnectAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/PumpDisconnectAction.kt
new file mode 100644
index 000000000000..1a3312a8d0cd
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/PumpDisconnectAction.kt
@@ -0,0 +1,58 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.RM
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcLoopDisconnected
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import org.json.JSONObject
+import app.aaps.core.ui.R as CoreUiR
+
+class PumpDisconnectAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_pump_disconnect
+ override val elementType = ElementType.PUMP
+ override val argType = listOf(ArgType.DURATION)
+ override val icon = IcLoopDisconnected
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.loopDisconnected }
+
+ override suspend fun getDefaultParams(): JSONObject =
+ JSONObject().put(NfcJsonKeys.DURATION, 30)
+
+ override suspend fun formatParams(): String {
+ val duration = params.optInt(NfcJsonKeys.DURATION, 30).coerceIn(1, 180)
+ return plugin.rh.gs(CoreUiR.string.format_mins, duration)
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val duration = params.optInt(NfcJsonKeys.DURATION, 30).coerceIn(1, 180)
+ val profile = plugin.profileFunction.getProfile() ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.noprofile))
+
+ val result = plugin.loop.handleRunningModeChange(
+ durationInMinutes = duration,
+ profile = profile,
+ newRM = RM.Mode.DISCONNECTED_PUMP,
+ action = Action.DISCONNECT,
+ source = source,
+ listValues = listOf(
+ ValueWithUnit.RMMode(RM.Mode.DISCONNECTED_PUMP),
+ ValueWithUnit.Minute(duration),
+ ValueWithUnit.SimpleString(params.optString(NfcJsonKeys.TAG_NAME, ""))
+ )
+ )
+ val message = if (result) {
+ plugin.rh.gs(CoreUiR.string.pump_disconnected) + " (" + plugin.rh.gs(CoreUiR.string.format_mins, duration) + ")"
+ } else {
+ plugin.rh.gs(R.string.nfccommands_remote_command_not_possible)
+ }
+ return NfcExecutionResult(result, message)
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/RestartAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/RestartAction.kt
new file mode 100644
index 000000000000..362416a31b97
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/RestartAction.kt
@@ -0,0 +1,29 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.Sources
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.icons.IcAaps
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+
+class RestartAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_restart_aaps
+ override val elementType = ElementType.AAPS
+ override val argType = listOf()
+ override val icon = IcAaps
+
+ override suspend fun execute(): NfcExecutionResult {
+ uel.log(
+ action = Action.EXIT_AAPS,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, "")
+ )
+ plugin.configBuilder.exitApp("NFC", Sources.NfcCommands, true)
+ return NfcExecutionResult(true, plugin.rh.gs(R.string.nfccommands_restarting))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/RunSceneAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/RunSceneAction.kt
new file mode 100644
index 000000000000..805d61277f81
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/RunSceneAction.kt
@@ -0,0 +1,77 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.graphics.vector.ImageVector
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.interfaces.scenes.SceneAutomationResult
+import app.aaps.core.ui.compose.navigation.icon
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import org.json.JSONObject
+import app.aaps.plugins.sync.R
+import app.aaps.core.ui.R as CoreUiR
+
+class RunSceneAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_run_scene
+ override val elementType = ElementType.SCENE
+ override val argType = listOf(ArgType.SCENE_ID)
+ override val icon
+ get() = elementType.icon()
+ override val secondaryIcon: ImageVector?
+ get() = params.optString(NfcJsonKeys.SCENE_ID).let { plugin.sceneIconResolver.iconForScene(it) }
+
+ override fun isSupported(): Boolean {
+ return plugin.sceneAutomationApi.getScenes().isNotEmpty()
+ }
+
+ override suspend fun getDefaultParams(): JSONObject {
+ val firstScene = plugin.sceneAutomationApi.getScenes().firstOrNull()
+ return JSONObject().put(NfcJsonKeys.SCENE_ID, firstScene?.id ?: "")
+ }
+
+ override suspend fun formatParams(): String? {
+ val sceneId = params.optString(NfcJsonKeys.SCENE_ID)
+ return plugin.sceneAutomationApi.getScene(sceneId)?.name
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val sceneId = params.optString(NfcJsonKeys.SCENE_ID)
+ if (sceneId.isNullOrBlank()) return invalidFormat()
+ val sceneName = plugin.sceneAutomationApi.getScene(sceneId)?.name ?: sceneId
+
+ return when (val result = plugin.sceneAutomationApi.runScene(sceneId)) {
+ SceneAutomationResult.Success -> {
+ uel.log(
+ action = Action.SCENE_ACTIVATED,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(ValueWithUnit.SimpleString(sceneName))
+ )
+ NfcExecutionResult(true, sceneName)
+ }
+
+ SceneAutomationResult.SceneNotFound ->
+ NfcExecutionResult(false, plugin.rh.gs(R.string.nfccommands_scene_not_found))
+
+ SceneAutomationResult.SceneDisabled ->
+ NfcExecutionResult(false, plugin.rh.gs(R.string.nfccommands_scene_disabled))
+
+ is SceneAutomationResult.Failed ->
+ NfcExecutionResult(false, result.message ?: plugin.rh.gs(CoreUiR.string.error))
+
+ is SceneAutomationResult.ChainCompleted -> {
+ uel.log(
+ action = Action.SCENE_ACTIVATED,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(ValueWithUnit.SimpleString(sceneName))
+ )
+ NfcExecutionResult(true, sceneName)
+ }
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempBasalAbsoluteAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempBasalAbsoluteAction.kt
new file mode 100644
index 000000000000..2001d3241e67
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempBasalAbsoluteAction.kt
@@ -0,0 +1,77 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.pump.defs.PumpDescription
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.interfaces.pump.PumpSync
+import app.aaps.core.objects.constraints.ConstraintObject
+import app.aaps.core.ui.compose.navigation.icon
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import org.json.JSONObject
+import app.aaps.core.ui.R as CoreUiR
+
+class TempBasalAbsoluteAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_basal_absolute
+ override val elementType = ElementType.TEMP_BASAL
+ override val argType = listOf(ArgType.RATE, ArgType.DURATION)
+ override val icon
+ get() = elementType.icon()
+
+ override suspend fun getDefaultParams(): JSONObject =
+ JSONObject().put(NfcJsonKeys.RATE, 0.0).put(NfcJsonKeys.DURATION, plugin.pumpBasalDurationStep())
+
+ override fun isSupported(): Boolean =
+ plugin.activePlugin.activePump.pumpDescription.tempBasalStyle == PumpDescription.ABSOLUTE
+
+ override suspend fun formatParams(): String {
+ val tempBasal = params.optDouble(NfcJsonKeys.RATE, 0.0)
+ val durationStep = plugin.pumpBasalDurationStep()
+ val rawDuration = params.optInt(NfcJsonKeys.DURATION, durationStep)
+ val duration = plugin.roundUpToStep(rawDuration, durationStep)
+
+ val rate = plugin.rh.gs(CoreUiR.string.pump_base_basal_rate, tempBasal)
+ val mins = plugin.rh.gs(CoreUiR.string.format_mins, duration)
+ return "$rate $mins"
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val profile = plugin.profileFunction.getProfile() ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.noprofile))
+ var tempBasal = params.optDouble(NfcJsonKeys.RATE, 0.0)
+ val durationStep = plugin.pumpBasalDurationStep()
+ val rawDuration = params.optInt(NfcJsonKeys.DURATION, durationStep)
+
+ if (rawDuration <= 0) return invalidFormat()
+
+ val duration = plugin.roundUpToStep(rawDuration, durationStep)
+ tempBasal = plugin.constraintChecker.applyBasalConstraints(ConstraintObject(tempBasal, plugin.aapsLogger), profile).value()
+ val result = plugin.commandQueue.tempBasalAbsolute(
+ tempBasal,
+ duration,
+ true,
+ profile,
+ PumpSync.TemporaryBasalType.NORMAL,
+ )
+ if (result.success) {
+ uel.log(
+ action = Action.TEMP_BASAL,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.UnitPerHour(tempBasal),
+ ValueWithUnit.Minute(duration)
+ )
+ )
+ return NfcExecutionResult(true, formatParams())
+ } else {
+ plugin.aapsLogger.error(LTag.NFC, "tempBasalAbsolute failed: ${result.comment}")
+ return commandNotPossible()
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempBasalPercentAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempBasalPercentAction.kt
new file mode 100644
index 000000000000..e5e173dabbd1
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempBasalPercentAction.kt
@@ -0,0 +1,76 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.pump.defs.PumpDescription
+import app.aaps.core.interfaces.logging.LTag
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.interfaces.pump.PumpSync
+import app.aaps.core.objects.constraints.ConstraintObject
+import app.aaps.core.ui.compose.icons.IcTbrLow
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import org.json.JSONObject
+import app.aaps.core.ui.R as CoreUiR
+
+class TempBasalPercentAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = R.string.nfccommands_cmd_basal_percent
+ override val elementType = ElementType.TEMP_BASAL
+ override val argType = listOf(ArgType.PERCENT, ArgType.DURATION)
+ override val icon = IcTbrLow
+
+ override suspend fun getDefaultParams(): JSONObject =
+ JSONObject().put(NfcJsonKeys.PERCENT, 100).put(NfcJsonKeys.DURATION, plugin.pumpBasalDurationStep())
+
+ override fun isSupported(): Boolean =
+ plugin.activePlugin.activePump.pumpDescription.tempBasalStyle == PumpDescription.PERCENT
+
+ override suspend fun formatParams(): String {
+ val tempBasalPct = params.optInt(NfcJsonKeys.PERCENT, 100)
+ val durationStep = plugin.pumpBasalDurationStep()
+ val rawDuration = params.optInt(NfcJsonKeys.DURATION, durationStep)
+ val duration = plugin.roundUpToStep(rawDuration, durationStep)
+
+ val pct = plugin.rh.gs(CoreUiR.string.format_percent, tempBasalPct)
+ val mins = plugin.rh.gs(CoreUiR.string.format_mins, duration)
+ return "$pct $mins"
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val profile = plugin.profileFunction.getProfile() ?: return NfcExecutionResult(false, plugin.rh.gs(CoreUiR.string.noprofile))
+ var tempBasalPct = params.optInt(NfcJsonKeys.PERCENT, 100)
+ val durationStep = plugin.pumpBasalDurationStep()
+ val rawDuration = params.optInt(NfcJsonKeys.DURATION, durationStep)
+
+ if (rawDuration <= 0) return invalidFormat()
+
+ val duration = plugin.roundUpToStep(rawDuration, durationStep)
+ tempBasalPct = plugin.constraintChecker.applyBasalPercentConstraints(ConstraintObject(tempBasalPct, plugin.aapsLogger), profile).value()
+ val result = plugin.commandQueue.tempBasalPercent(
+ tempBasalPct,
+ duration,
+ true,
+ profile,
+ PumpSync.TemporaryBasalType.NORMAL,
+ )
+ if (result.success) {
+ uel.log(
+ action = Action.TEMP_BASAL,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.Percent(tempBasalPct),
+ ValueWithUnit.Minute(duration)
+ )
+ )
+ return NfcExecutionResult(true, formatParams())
+ } else {
+ plugin.aapsLogger.error(LTag.NFC, "tempBasalPercent failed: ${result.comment}")
+ return commandNotPossible()
+ }
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetActivityAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetActivityAction.kt
new file mode 100644
index 000000000000..81bad32bef22
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetActivityAction.kt
@@ -0,0 +1,65 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.GlucoseUnit
+import app.aaps.core.data.model.TT
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.interfaces.tempTargets.ttDurationMinutes
+import app.aaps.core.interfaces.tempTargets.ttTargetMgdl
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcTtActivity
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import java.util.concurrent.TimeUnit
+import app.aaps.core.ui.R as CoreUiR
+
+class TempTargetActivityAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.activity
+ override val elementType = ElementType.TEMP_TARGET_MANAGEMENT
+ override val argType = listOf()
+ override val icon = IcTtActivity
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.exercise }
+
+ override suspend fun formatParams(): String {
+ val units = plugin.profileUtil.units
+ val ttDuration = plugin.preferences.ttDurationMinutes(TT.Reason.ACTIVITY)
+ val tt = plugin.profileUtil.fromMgdlToUnits(plugin.preferences.ttTargetMgdl(TT.Reason.ACTIVITY), plugin.profileUtil.units)
+ val ttString = if (units == GlucoseUnit.MMOL) plugin.decimalFormatter.to1Decimal(tt) else plugin.decimalFormatter.to0Decimal(tt)
+ val unitLabel = if (units == GlucoseUnit.MMOL) "mmol/l" else "mg/dl"
+ return "$ttString $unitLabel, ${ttDuration}min"
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val units = plugin.profileUtil.units
+ val ttDuration = plugin.preferences.ttDurationMinutes(TT.Reason.ACTIVITY)
+ val tt = plugin.profileUtil.fromMgdlToUnits(plugin.preferences.ttTargetMgdl(TT.Reason.ACTIVITY), plugin.profileUtil.units)
+ val reason = TT.Reason.ACTIVITY
+
+ plugin.persistenceLayer.insertAndCancelCurrentTemporaryTarget(
+ temporaryTarget = TT(
+ timestamp = plugin.dateUtil.now(),
+ duration = TimeUnit.MINUTES.toMillis(ttDuration.toLong()),
+ reason = reason,
+ lowTarget = plugin.profileUtil.convertToMgdl(tt, plugin.profileUtil.units),
+ highTarget = plugin.profileUtil.convertToMgdl(tt, plugin.profileUtil.units),
+ ),
+ action = Action.TT,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.TETTReason(reason),
+ ValueWithUnit.fromGlucoseUnit(tt, units),
+ ValueWithUnit.Minute(ttDuration),
+ ),
+ )
+ val ttString = if (units == GlucoseUnit.MMOL) plugin.decimalFormatter.to1Decimal(tt) else plugin.decimalFormatter.to0Decimal(tt)
+ return NfcExecutionResult(true, plugin.rh.gs(R.string.nfccommands_tt_set, ttString, ttDuration))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetCancelAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetCancelAction.kt
new file mode 100644
index 000000000000..1fa820a36729
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetCancelAction.kt
@@ -0,0 +1,40 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcTtCancel
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import app.aaps.core.ui.R as CoreUiR
+
+class TempTargetCancelAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.stoptemptarget
+ override val elementType = ElementType.TEMP_TARGET_MANAGEMENT
+ override val argType = listOf()
+ override val icon = IcTtCancel
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.loopDisabled }
+
+ override suspend fun execute(): NfcExecutionResult {
+ plugin.persistenceLayer.cancelCurrentTemporaryTargetIfAny(
+ timestamp = plugin.dateUtil.now(),
+ action = Action.CANCEL_TT,
+ source = source,
+ note = plugin.rh.gs(R.string.nfccommands_tt_canceled),
+ listValues = listOf(ValueWithUnit.SimpleString(plugin.rh.gsNotLocalised(R.string.nfccommands_tt_canceled))),
+ )
+ uel.log(
+ action = Action.CANCEL_TT,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, "")
+ )
+ return NfcExecutionResult(true, plugin.rh.gs(R.string.nfccommands_tt_canceled))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetHypoAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetHypoAction.kt
new file mode 100644
index 000000000000..b431478273d3
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetHypoAction.kt
@@ -0,0 +1,65 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.GlucoseUnit
+import app.aaps.core.data.model.TT
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.interfaces.tempTargets.ttDurationMinutes
+import app.aaps.core.interfaces.tempTargets.ttTargetMgdl
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcTtHypo
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import java.util.concurrent.TimeUnit
+import app.aaps.core.ui.R as CoreUiR
+
+class TempTargetHypoAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.hypo
+ override val elementType = ElementType.TEMP_TARGET_MANAGEMENT
+ override val argType = listOf()
+ override val icon = IcTtHypo
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.loopDisabled }
+
+ override suspend fun formatParams(): String {
+ val units = plugin.profileUtil.units
+ val ttDuration = plugin.preferences.ttDurationMinutes(TT.Reason.HYPOGLYCEMIA)
+ val tt = plugin.profileUtil.fromMgdlToUnits(plugin.preferences.ttTargetMgdl(TT.Reason.HYPOGLYCEMIA), plugin.profileUtil.units)
+ val ttString = if (units == GlucoseUnit.MMOL) plugin.decimalFormatter.to1Decimal(tt) else plugin.decimalFormatter.to0Decimal(tt)
+ val unitLabel = if (units == GlucoseUnit.MMOL) "mmol/l" else "mg/dl"
+ return "$ttString $unitLabel, ${ttDuration}min"
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val units = plugin.profileUtil.units
+ val ttDuration = plugin.preferences.ttDurationMinutes(TT.Reason.HYPOGLYCEMIA)
+ val tt = plugin.profileUtil.fromMgdlToUnits(plugin.preferences.ttTargetMgdl(TT.Reason.HYPOGLYCEMIA), plugin.profileUtil.units)
+ val reason = TT.Reason.HYPOGLYCEMIA
+
+ plugin.persistenceLayer.insertAndCancelCurrentTemporaryTarget(
+ temporaryTarget = TT(
+ timestamp = plugin.dateUtil.now(),
+ duration = TimeUnit.MINUTES.toMillis(ttDuration.toLong()),
+ reason = reason,
+ lowTarget = plugin.profileUtil.convertToMgdl(tt, plugin.profileUtil.units),
+ highTarget = plugin.profileUtil.convertToMgdl(tt, plugin.profileUtil.units),
+ ),
+ action = Action.TT,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.TETTReason(reason),
+ ValueWithUnit.fromGlucoseUnit(tt, units),
+ ValueWithUnit.Minute(ttDuration),
+ ),
+ )
+ val ttString = if (units == GlucoseUnit.MMOL) plugin.decimalFormatter.to1Decimal(tt) else plugin.decimalFormatter.to0Decimal(tt)
+ return NfcExecutionResult(true, plugin.rh.gs(R.string.nfccommands_tt_set, ttString, ttDuration))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetManualAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetManualAction.kt
new file mode 100644
index 000000000000..12eed0d7281c
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetManualAction.kt
@@ -0,0 +1,75 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import app.aaps.core.data.model.GlucoseUnit
+import app.aaps.core.data.model.TT
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.ui.compose.icons.IcTtManual
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.R
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit
+import app.aaps.core.ui.R as CoreUiR
+
+class TempTargetManualAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.custom
+ override val elementType = ElementType.TEMP_TARGET_MANAGEMENT
+ override val argType = listOf(ArgType.GLUCOSE_TARGET, ArgType.DURATION)
+ override val icon = IcTtManual
+
+ override suspend fun getDefaultParams(): JSONObject = JSONObject().apply {
+ val units = plugin.profileUtil.units
+ val profile = plugin.profileFunction.getProfile()
+ val defaultTargetMgdl = profile?.getTargetLowMgdl() ?: 100.0
+ val defaultTarget = plugin.profileUtil.fromMgdlToUnits(defaultTargetMgdl, units)
+
+ put(NfcJsonKeys.GLUCOSE, defaultTarget)
+ put(NfcJsonKeys.DURATION, 60)
+ }
+
+ override suspend fun formatParams(): String {
+ val units = plugin.profileUtil.units
+ val glucose = params.optDouble(NfcJsonKeys.GLUCOSE, 0.0)
+ val duration = params.optInt(NfcJsonKeys.DURATION, 0)
+ val unitLabel = if (units == GlucoseUnit.MMOL) "mmol/l" else "mg/dl"
+ val glucoseString = if (units == GlucoseUnit.MMOL) plugin.decimalFormatter.to1Decimal(glucose) else plugin.decimalFormatter.to0Decimal(glucose)
+ return "$glucoseString $unitLabel, ${duration}min"
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val units = plugin.profileUtil.units
+ val glucose = params.optDouble(NfcJsonKeys.GLUCOSE, 0.0)
+ val durationMinutes = params.optInt(NfcJsonKeys.DURATION, 60)
+
+ if (glucose <= 0.0 || durationMinutes <= 0) return invalidFormat()
+
+ val targetMgdl = plugin.profileUtil.convertToMgdl(glucose, units)
+ val reason = TT.Reason.CUSTOM
+
+ plugin.persistenceLayer.insertAndCancelCurrentTemporaryTarget(
+ temporaryTarget = TT(
+ timestamp = plugin.dateUtil.now(),
+ duration = TimeUnit.MINUTES.toMillis(durationMinutes.toLong()),
+ reason = reason,
+ lowTarget = targetMgdl,
+ highTarget = targetMgdl,
+ ),
+ action = Action.TT,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.TETTReason(reason),
+ ValueWithUnit.fromGlucoseUnit(glucose, units),
+ ValueWithUnit.Minute(durationMinutes),
+ ),
+ )
+
+ val ttString = if (units == GlucoseUnit.MMOL) plugin.decimalFormatter.to1Decimal(glucose) else plugin.decimalFormatter.to0Decimal(glucose)
+ return NfcExecutionResult(true, plugin.rh.gs(R.string.nfccommands_tt_set, ttString, durationMinutes))
+ }
+}
diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetMealAction.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetMealAction.kt
new file mode 100644
index 000000000000..d0731e6a3d89
--- /dev/null
+++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nfcCommands/actions/TempTargetMealAction.kt
@@ -0,0 +1,65 @@
+package app.aaps.plugins.sync.nfcCommands.actions
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import app.aaps.core.data.model.GlucoseUnit
+import app.aaps.core.data.model.TT
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.ValueWithUnit
+import app.aaps.core.interfaces.navigation.ElementType
+import app.aaps.core.interfaces.tempTargets.ttDurationMinutes
+import app.aaps.core.interfaces.tempTargets.ttTargetMgdl
+import app.aaps.core.ui.compose.AapsTheme
+import app.aaps.core.ui.compose.icons.IcTtEatingSoon
+import app.aaps.plugins.sync.nfcCommands.ArgType
+import app.aaps.plugins.sync.nfcCommands.NfcCommandsPlugin
+import app.aaps.plugins.sync.nfcCommands.NfcExecutionResult
+import app.aaps.plugins.sync.nfcCommands.NfcJsonKeys
+import app.aaps.plugins.sync.R
+import java.util.concurrent.TimeUnit
+import app.aaps.core.ui.R as CoreUiR
+
+class TempTargetMealAction(plugin: NfcCommandsPlugin) : NfcAction(plugin) {
+ @StringRes override val labelResId = CoreUiR.string.eatingsoon
+ override val elementType = ElementType.TEMP_TARGET_MANAGEMENT
+ override val argType = listOf()
+ override val icon = IcTtEatingSoon
+ override val customIconColor: @Composable () -> Color = { AapsTheme.elementColors.tempTarget }
+
+ override suspend fun formatParams(): String {
+ val units = plugin.profileUtil.units
+ val ttDuration = plugin.preferences.ttDurationMinutes(TT.Reason.EATING_SOON)
+ val tt = plugin.profileUtil.fromMgdlToUnits(plugin.preferences.ttTargetMgdl(TT.Reason.EATING_SOON), plugin.profileUtil.units)
+ val ttString = if (units == GlucoseUnit.MMOL) plugin.decimalFormatter.to1Decimal(tt) else plugin.decimalFormatter.to0Decimal(tt)
+ val unitLabel = if (units == GlucoseUnit.MMOL) "mmol/l" else "mg/dl"
+ return "$ttString $unitLabel, ${ttDuration}min"
+ }
+
+ override suspend fun execute(): NfcExecutionResult {
+ val units = plugin.profileUtil.units
+ val ttDuration = plugin.preferences.ttDurationMinutes(TT.Reason.EATING_SOON)
+ val tt = plugin.profileUtil.fromMgdlToUnits(plugin.preferences.ttTargetMgdl(TT.Reason.EATING_SOON), plugin.profileUtil.units)
+ val reason = TT.Reason.EATING_SOON
+
+ plugin.persistenceLayer.insertAndCancelCurrentTemporaryTarget(
+ temporaryTarget = TT(
+ timestamp = plugin.dateUtil.now(),
+ duration = TimeUnit.MINUTES.toMillis(ttDuration.toLong()),
+ reason = reason,
+ lowTarget = plugin.profileUtil.convertToMgdl(tt, plugin.profileUtil.units),
+ highTarget = plugin.profileUtil.convertToMgdl(tt, plugin.profileUtil.units),
+ ),
+ action = Action.TT,
+ source = source,
+ note = params.optString(NfcJsonKeys.TAG_NAME, ""),
+ listValues = listOf(
+ ValueWithUnit.TETTReason(reason),
+ ValueWithUnit.fromGlucoseUnit(tt, units),
+ ValueWithUnit.Minute(ttDuration),
+ ),
+ )
+ val ttString = if (units == GlucoseUnit.MMOL) plugin.decimalFormatter.to1Decimal(tt) else plugin.decimalFormatter.to0Decimal(tt)
+ return NfcExecutionResult(true, plugin.rh.gs(R.string.nfccommands_tt_set, ttString, ttDuration))
+ }
+}
diff --git a/plugins/sync/src/main/res/values/strings.xml b/plugins/sync/src/main/res/values/strings.xml
index cf0d07b427a8..8c14994e04a2 100644
--- a/plugins/sync/src/main/res/values/strings.xml
+++ b/plugins/sync/src/main/res/values/strings.xml
@@ -270,4 +270,90 @@
SMS
Application needs SMS permission for remote control via text messages
+
+
+ NFC Commands
+ NFC
+ Trigger AAPS commands by scanning an NFC tag.
+ Register NFC Tag
+ NFC Tag written successfully
+ Could not write NDEF data — tag assigned by UID (generic tag mode)
+ Tag reassigned successfully
+ NFC Command executed: %1$s
+ NFC communicator is disabled
+ This NFC tag is not registered in AAPS. Use My Tags to register it.
+ Tag name
+ AAPSClient restart sent
+ Disable tag
+ %1$d. %2$s
+ Add at least one command before writing.
+ Temp Targets
+ System
+ Command Chain
+ Hold phone near NFC tag
+ Register tag without name?
+ This tag has no name. It will still be registered and shown later with the first command as its name.
+ Register anyway
+ No additional input needed
+ Set TT Eating Soon
+ Basal rate (U/h)
+ Amount (U)
+ No tags yet
+ Build a command chain and register an NFC tag.
+ Delete tag?
+ This tag will be removed from My Tags. The physical NFC tag will no longer trigger any commands.
+ Unknown command or wrong reply
+ Remote command is not allowed
+ Remote command is not possible
+ Loop has been disabled
+ Loop resumed
+ Wrong duration
+ Current loop mode: %1$s
+ Pump reconnected
+ Profile switch created
+ Cancel extended bolus
+ Extended bolus %1$.2fU for %2$d min
+ There is another bolus in queue. Try again later.
+ Remote bolus not available. Try again later.
+ Carbs %1$d g
+ Target %1$s for %2$d minutes
+ Temp Target canceled
+ Scene not found
+ Scene is disabled
+ AAPS is restarting
+ Stop Loop
+ Close Loop
+ Low Glucose Suspend
+ Run Scene
+ Restart AAPSClient
+ Connect Pump
+ Disconnect Pump
+ Basal Absolute
+ Basal Percent
+ Cancel extended bolus
+ Restart AAPS
+ No activity yet
+ NFC tag reads and writes will appear here
+ Add tag
+ Log
+ Read
+ Write
+ Manual
+ Clear log
+ Remove all activity log entries
+ Log cleared
+ Modify Command
+ Discard changes?
+ You have unsaved changes. Do you want to save them before leaving?
+ Tag ID:
+ Execute tag
+ Tag already registered
+ This tag is already registered as \"%1$s\". Do you want to overwrite it with \"%2$s\"?
+ Overwrite
+ Discard and exit
+ Scan another tag
+ NFC Foreground Priority
+ When enabled, AndroidAPS will intercept all NFC tags while the app is open, including Freestyle Libre sensors.\n\nThis may prevent other apps such as LibreLink from reading your sensor while AndroidAPS is in the foreground.\n\nOnly enable this if you know what you are doing.
+
+
\ No newline at end of file
diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcBuildScreenTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcBuildScreenTest.kt
new file mode 100644
index 000000000000..aa22b11dbbce
--- /dev/null
+++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcBuildScreenTest.kt
@@ -0,0 +1,27 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class NfcBuildScreenTest {
+
+ @Test
+ fun `resolveWriteOutcome returns REASSIGNED when already assigned`() {
+ assertThat(resolveWriteOutcome(alreadyAssigned = true, ndefWritten = false))
+ .isEqualTo(WriteOutcome.REASSIGNED)
+ assertThat(resolveWriteOutcome(alreadyAssigned = true, ndefWritten = true))
+ .isEqualTo(WriteOutcome.REASSIGNED)
+ }
+
+ @Test
+ fun `resolveWriteOutcome returns NDEF_WRITTEN on successful write of new tag`() {
+ assertThat(resolveWriteOutcome(alreadyAssigned = false, ndefWritten = true))
+ .isEqualTo(WriteOutcome.NDEF_WRITTEN)
+ }
+
+ @Test
+ fun `resolveWriteOutcome returns GENERIC_ASSIGNED when NDEF write fails`() {
+ assertThat(resolveWriteOutcome(alreadyAssigned = false, ndefWritten = false))
+ .isEqualTo(WriteOutcome.GENERIC_ASSIGNED)
+ }
+}
diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandsPluginTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandsPluginTest.kt
new file mode 100644
index 000000000000..839563d60f6e
--- /dev/null
+++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcCommandsPluginTest.kt
@@ -0,0 +1,663 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import app.aaps.core.data.configuration.Constants
+import app.aaps.core.data.model.RM
+import app.aaps.core.data.plugin.PluginType
+import app.aaps.core.data.ue.Action
+import app.aaps.core.data.ue.Sources
+import app.aaps.core.interfaces.configuration.ConfigBuilder
+import app.aaps.core.interfaces.db.PersistenceLayer
+import app.aaps.core.interfaces.iob.GlucoseStatusProvider
+import app.aaps.core.interfaces.logging.UserEntryLogger
+import app.aaps.core.interfaces.profile.ProfileStore
+import app.aaps.core.interfaces.pump.BolusProgressData
+import app.aaps.core.interfaces.queue.CommandQueue
+import app.aaps.core.interfaces.scenes.SceneAutomationApi
+import app.aaps.core.interfaces.scenes.SceneAutomationResult
+import app.aaps.core.interfaces.scenes.SceneIconResolver
+import app.aaps.core.keys.BooleanKey
+import app.aaps.core.keys.StringNonKey
+import app.aaps.core.objects.wizard.BolusWizard
+import app.aaps.plugins.sync.R
+import app.aaps.shared.tests.TestBaseWithProfile
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.json.JSONObject
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.Mock
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import app.aaps.core.ui.R as CoreUiR
+
+class NfcCommandsPluginTest : TestBaseWithProfile() {
+ @Mock lateinit var commandQueue: CommandQueue
+ @Mock lateinit var loop: app.aaps.core.interfaces.aps.Loop
+ @Mock lateinit var persistenceLayer: PersistenceLayer
+ @Mock lateinit var configBuilder: ConfigBuilder
+ @Mock lateinit var mockProfileStore: ProfileStore
+ @Mock lateinit var uel: UserEntryLogger
+ @Mock lateinit var bolusWizard: BolusWizard
+ @Mock lateinit var glucoseStatusProvider: GlucoseStatusProvider
+ @Mock lateinit var sceneAutomationApi: SceneAutomationApi
+ @Mock lateinit var bolusProgressData: BolusProgressData
+ @Mock lateinit var sceneIconResolver: SceneIconResolver
+
+ private val tagUid = "aabbccdd"
+ private lateinit var plugin: NfcCommandsPlugin
+
+ @BeforeEach
+ fun setupPlugin() {
+ plugin =
+ NfcCommandsPlugin(
+ context = context,
+ aapsLogger = aapsLogger,
+ rh = rh,
+ preferences = preferences,
+ nfcTagStore = NfcTagStore(TestSp()),
+ constraintChecker = constraintsChecker,
+ profileFunction = profileFunction,
+ profileUtil = profileUtil,
+ profileRepository = profileRepository,
+ insulin = insulin,
+ activePlugin = activePlugin,
+ commandQueue = commandQueue,
+ loop = loop,
+ dateUtil = dateUtil,
+ persistenceLayer = persistenceLayer,
+ decimalFormatter = decimalFormatter,
+ configBuilder = configBuilder,
+ rxBus = rxBus,
+ uel = uel,
+ bolusWizard = bolusWizard,
+ iobCobCalculator = iobCobCalculator,
+ bolusProgressData = bolusProgressData,
+ glucoseStatusProvider = glucoseStatusProvider,
+ sceneAutomationApi = sceneAutomationApi,
+ sceneIconResolver = sceneIconResolver,
+ )
+ plugin.setPluginEnabledBlocking(PluginType.SYNC, true)
+
+ runTest {
+ whenever(profileFunction.getProfile()).thenReturn(effectiveProfile)
+ whenever(commandQueue.cancelTempBasal(any(), any())).thenReturn(pumpEnactResultProvider.get().success(true))
+ whenever(commandQueue.cancelExtended()).thenReturn(pumpEnactResultProvider.get().success(true))
+ whenever(commandQueue.bolus(any())).thenReturn(pumpEnactResultProvider.get().success(true))
+ whenever(commandQueue.tempBasalPercent(any(), any(), any(), any(), any())).thenReturn(pumpEnactResultProvider.get().success(true))
+ whenever(commandQueue.tempBasalAbsolute(any(), any(), any(), any(), any())).thenReturn(pumpEnactResultProvider.get().success(true))
+ whenever(commandQueue.extendedBolus(any(), any())).thenReturn(pumpEnactResultProvider.get().success(true))
+ }
+ whenever(preferences.get(BooleanKey.NfcAllowRemoteCommands)).thenReturn(true)
+ whenever(rh.gs(any())).thenReturn("Mock String")
+ whenever(rh.gs(any(), any())).thenReturn("Mock String")
+ whenever(rh.gs(any(), any(), any())).thenReturn("Mock String")
+ whenever(rh.gsNotLocalised(any())).thenReturn("Mock String")
+ whenever(rh.gsNotLocalised(any(), any())).thenReturn("Mock String")
+ whenever(rh.gs(R.string.wrong_format)).thenReturn("Wrong format")
+ whenever(rh.gs(R.string.nfccommands_wrong_duration)).thenReturn("Wrong duration")
+ whenever(rh.gs(CoreUiR.string.pump_disconnected)).thenReturn("Pump disconnected")
+ whenever(rh.gs(CoreUiR.string.noprofile)).thenReturn("No profile")
+ whenever(rh.gs(CoreUiR.string.ok)).thenReturn("OK")
+ }
+
+ private fun execute(command: String): NfcExecutionResult = runBlocking { plugin.executeCommand(command) }
+ private fun execute(code: NfcCommandCode, params: JSONObject = JSONObject()): NfcExecutionResult = execute(NfcTagStore.buildCommand(code, params))
+
+ private fun cascade(commands: List): NfcExecutionResult = runBlocking { plugin.executeCascade(commands) }
+
+ @Test
+ fun `NfcCategories build should include new categories and commands`() {
+ whenever(sceneAutomationApi.getScenes()).thenReturn(listOf(mock()))
+ testPumpPlugin.pumpDescription.bolusStep = 0.1
+
+ val categories = NfcCategories.build(plugin)
+
+ assertThat(categories.any { it.labelResId == CoreUiR.string.scenes }).isTrue()
+
+ val scenesCat = categories.find { it.labelResId == CoreUiR.string.scenes }
+ assertThat(scenesCat?.commands).contains(NfcCommandCode.RUN_SCENE)
+
+ val treatmentsCat = categories.find { it.labelResId == CoreUiR.string.treatments }
+ assertThat(treatmentsCat?.commands).contains(NfcCommandCode.BOLUS_WIZARD)
+ }
+
+ // ── prepareExecution tests ─────────────────────────────────────────────────
+
+ @Test
+ fun `prepareExecution returns Ready with commands when tag registered`() {
+ val cmd = NfcTagStore.buildCommand(NfcCommandCode.LOOP_STOP)
+ val tag = NfcCreatedTag(tagUid = tagUid, name = "Test", commands = listOf(cmd), createdAtMillis = 0L)
+ plugin.nfcTagStore.saveCreatedTag(tag)
+ whenever(rh.gs(R.string.nfccommands_tag_not_registered)).thenReturn("Not registered")
+
+ val result = plugin.prepareExecution(tagUid)
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Ready::class.java)
+ val ready = result as NfcPrepareResult.Ready
+ assertThat(ready.commands).isEqualTo(listOf(cmd))
+ assertThat(ready.tagName).isEqualTo("Test")
+ }
+
+ @Test
+ fun `prepareExecution returns Error when plugin disabled`() {
+ plugin.setPluginEnabledBlocking(PluginType.SYNC, false)
+ whenever(rh.gs(R.string.nfccommands_plugin_disabled)).thenReturn("Plugin disabled")
+
+ val result = plugin.prepareExecution(tagUid)
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Error::class.java)
+ }
+
+ // ── processLoop tests ──────────────────────────────────────────────────────
+
+ @Test
+ fun `executeCommand LOOP_STOP should disable loop`() {
+ runTest { whenever(loop.allowedNextModes()).thenReturn(listOf(RM.Mode.DISABLED_LOOP)) }
+ runTest { whenever(loop.handleRunningModeChange(any(), any(), any(), any(), any(), any())).thenReturn(true) }
+ whenever(rh.gs(R.string.nfccommands_loop_has_been_disabled)).thenReturn("Loop disabled")
+
+ val result = execute(NfcCommandCode.LOOP_STOP)
+
+ assertThat(result.success).isTrue()
+ runTest {
+ verify(loop).handleRunningModeChange(
+ eq(RM.Mode.DISABLED_LOOP),
+ eq(Action.LOOP_DISABLED),
+ eq(Sources.NfcCommands),
+ any(),
+ eq(Int.MAX_VALUE),
+ eq(effectiveProfile),
+ )
+ }
+ }
+
+ @Test
+ fun `executeCommand LOOP_RESUME should resume loop`() {
+ runTest { whenever(loop.allowedNextModes()).thenReturn(listOf(RM.Mode.RESUME)) }
+ runTest { whenever(loop.handleRunningModeChange(any(), any(), any(), any(), any(), any())).thenReturn(true) }
+ whenever(rh.gs(R.string.nfccommands_loop_resumed)).thenReturn("Loop resumed")
+
+ val result = execute(NfcCommandCode.LOOP_RESUME)
+
+ assertThat(result.success).isTrue()
+ runTest {
+ verify(loop).handleRunningModeChange(
+ eq(RM.Mode.RESUME),
+ eq(Action.RESUME),
+ eq(Sources.NfcCommands),
+ any(),
+ any(),
+ eq(effectiveProfile),
+ )
+ }
+ }
+
+ @Test
+ fun `executeCommand LOOP_SUSPEND should call handleRunningModeChange directly`() {
+ runTest { whenever(loop.allowedNextModes()).thenReturn(listOf(RM.Mode.SUSPENDED_BY_USER)) }
+ runTest { whenever(loop.handleRunningModeChange(any(), any(), any(), any(), any(), any())).thenReturn(true) }
+ whenever(rh.gs(CoreUiR.string.loopsuspended)).thenReturn("Loop suspended")
+
+ val result = execute(NfcCommandCode.LOOP_SUSPEND, JSONObject().put(NfcJsonKeys.DURATION, 30))
+
+ assertThat(result.success).isTrue()
+ runTest {
+ verify(loop).handleRunningModeChange(
+ eq(RM.Mode.SUSPENDED_BY_USER),
+ eq(Action.SUSPEND),
+ eq(Sources.NfcCommands),
+ any(),
+ eq(30),
+ eq(effectiveProfile),
+ )
+ }
+ }
+
+ @Test
+ fun `executeCommand LOOP_LGS should switch to LGS mode`() {
+ runTest { whenever(loop.allowedNextModes()).thenReturn(listOf(RM.Mode.CLOSED_LOOP_LGS)) }
+ runTest { whenever(loop.handleRunningModeChange(any(), any(), any(), any(), any(), any())).thenReturn(true) }
+ whenever(rh.gs(CoreUiR.string.lowglucosesuspend)).thenReturn("LGS")
+ whenever(rh.gs(eq(R.string.nfccommands_current_loop_mode), any())).thenReturn("LGS mode")
+
+ val result = execute(NfcCommandCode.LOOP_LGS)
+
+ assertThat(result.success).isTrue()
+ runTest {
+ verify(loop).handleRunningModeChange(
+ eq(RM.Mode.CLOSED_LOOP_LGS),
+ eq(Action.LGS_LOOP_MODE),
+ eq(Sources.NfcCommands),
+ any(),
+ any(),
+ eq(effectiveProfile),
+ )
+ }
+ }
+
+ @Test
+ fun `executeCommand LOOP_CLOSED should switch to closed loop`() {
+ runTest { whenever(loop.allowedNextModes()).thenReturn(listOf(RM.Mode.CLOSED_LOOP)) }
+ runTest { whenever(loop.handleRunningModeChange(any(), any(), any(), any(), any(), any())).thenReturn(true) }
+ whenever(rh.gs(CoreUiR.string.closedloop)).thenReturn("Closed")
+ whenever(rh.gs(eq(R.string.nfccommands_current_loop_mode), any())).thenReturn("Closed loop")
+
+ val result = execute(NfcCommandCode.LOOP_CLOSED)
+
+ assertThat(result.success).isTrue()
+ runTest {
+ verify(loop).handleRunningModeChange(
+ eq(RM.Mode.CLOSED_LOOP),
+ eq(Action.CLOSED_LOOP_MODE),
+ eq(Sources.NfcCommands),
+ any(),
+ any(),
+ eq(effectiveProfile),
+ )
+ }
+ }
+
+ // ── processAapsClient tests ────────────────────────────────────────────────
+ /* Command removed
+ @Test
+ fun `executeCommand AAPSCLIENT_RESTART should send restart event`() {
+ whenever(rh.gs(R.string.nfccommands_aapsclient_restart_sent)).thenReturn("AAPSClient restart sent")
+
+ val result = execute(NfcCommandCode.AAPSCLIENT_RESTART)
+
+ assertThat(result.success).isTrue()
+ assertThat(result.message).isEqualTo("AAPSClient restart sent")
+ }
+
+ */
+
+ // ── processPump tests ──────────────────────────────────────────────────────
+
+ @Test
+ fun `executeCommand PUMP_DISCONNECT should disconnect pump`() {
+ runTest { whenever(loop.handleRunningModeChange(any(), any(), any(), any(), any(), any())).thenReturn(true) }
+ whenever(rh.gs(CoreUiR.string.pump_disconnected)).thenReturn("Pump disconnected")
+
+ val result = execute(NfcCommandCode.PUMP_DISCONNECT, JSONObject().put(NfcJsonKeys.DURATION, 180))
+
+ assertThat(result.success).isTrue()
+ runTest {
+ verify(loop).handleRunningModeChange(
+ eq(RM.Mode.DISCONNECTED_PUMP),
+ eq(Action.DISCONNECT),
+ eq(Sources.NfcCommands),
+ any(),
+ eq(180),
+ eq(effectiveProfile),
+ )
+ }
+ }
+
+ @Test
+ fun `executeCommand PUMP_CONNECT returns connected when reconnect is not needed`() {
+ runTest { whenever(loop.allowedNextModes()).thenReturn(emptyList()) }
+ whenever(rh.gs(app.aaps.core.interfaces.R.string.connected)).thenReturn("Connected")
+
+ val result = execute(NfcCommandCode.PUMP_CONNECT)
+
+ assertThat(result.success).isTrue()
+ assertThat(result.message).isEqualTo("Connected")
+ }
+
+ // ── processBasal tests ─────────────────────────────────────────────────────
+
+ @Test
+ fun `executeCommand BASAL_STOP should cancel temp basal`() {
+ whenever(rh.gs(CoreUiR.string.stoptemptarget)).thenReturn("Temp basal canceled")
+
+ val result = execute(NfcCommandCode.BASAL_STOP)
+
+ assertThat(result.success).isTrue()
+ runTest { verify(commandQueue).cancelTempBasal(eq(true), any()) }
+ }
+
+ @Test
+ fun `executeCommand BASAL_PCT should enqueue percent temp basal`() {
+ whenever(constraintsChecker.applyBasalPercentConstraints(any(), any())).thenReturn(
+ app.aaps.core.objects.constraints.ConstraintObject(120, aapsLogger),
+ )
+ whenever(rh.gs(eq(R.string.nfccommands_command_executed), any())).thenReturn("Command executed")
+
+ val result = execute(NfcCommandCode.BASAL_PCT, JSONObject().put(NfcJsonKeys.PERCENT, 120).put(NfcJsonKeys.DURATION, 30))
+
+ assertThat(result.success).isTrue()
+ runTest { verify(commandQueue).tempBasalPercent(any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `executeCommand BASAL_ABS should enqueue absolute temp basal`() {
+ whenever(constraintsChecker.applyBasalConstraints(any(), any())).thenReturn(
+ app.aaps.core.objects.constraints.ConstraintObject(1.5, aapsLogger),
+ )
+ whenever(rh.gs(eq(R.string.nfccommands_command_executed), any())).thenReturn("Command executed")
+
+ val result = execute(NfcCommandCode.BASAL_ABS, JSONObject().put(NfcJsonKeys.RATE, 1.5).put(NfcJsonKeys.DURATION, 30))
+
+ assertThat(result.success).isTrue()
+ runTest { verify(commandQueue).tempBasalAbsolute(any(), any(), any(), any(), any()) }
+ }
+
+ // ── processExtended tests ──────────────────────────────────────────────────
+
+ @Test
+ fun `executeCommand EXTENDED_STOP should cancel extended bolus`() {
+ whenever(rh.gs(R.string.nfccommands_extended_canceled)).thenReturn("Extended canceled")
+
+ val result = execute(NfcCommandCode.EXTENDED_STOP)
+
+ assertThat(result.success).isTrue()
+ runTest { verify(commandQueue).cancelExtended() }
+ }
+
+ @Test
+ fun `executeCommand EXTENDED_SET should enqueue extended bolus`() {
+ whenever(constraintsChecker.applyExtendedBolusConstraints(any())).thenReturn(
+ app.aaps.core.objects.constraints.ConstraintObject(2.0, aapsLogger),
+ )
+ whenever(rh.gs(eq(R.string.nfccommands_extended_set), any(), any())).thenReturn("Extended set")
+
+ val result = execute(NfcCommandCode.EXTENDED_SET, JSONObject().put(NfcJsonKeys.AMOUNT, 2.0).put(NfcJsonKeys.DURATION, 60))
+
+ assertThat(result.success).isTrue()
+ runTest { verify(commandQueue).extendedBolus(any(), any()) }
+ }
+
+ // ── processBolus tests ─────────────────────────────────────────────────────
+
+ @Test
+ fun `executeCommand BOLUS should enqueue bolus`() {
+ whenever(constraintsChecker.applyBolusConstraints(any())).thenReturn(
+ app.aaps.core.objects.constraints.ConstraintObject(1.0, aapsLogger),
+ )
+ whenever(commandQueue.bolusInQueue()).thenReturn(false)
+ whenever(bolusProgressData.isStopPressed).thenReturn(false)
+ runTest { whenever(loop.runningMode()).thenReturn(RM.Mode.CLOSED_LOOP) }
+ whenever(rh.gs(eq(R.string.nfccommands_command_executed), any())).thenReturn("Command executed")
+
+ val result = execute(NfcCommandCode.BOLUS, JSONObject().put(NfcJsonKeys.AMOUNT, 1.0))
+
+ assertThat(result.success).isTrue()
+ runTest { verify(commandQueue).bolus(any()) }
+ }
+
+ @Test
+ fun `executeCommand BOLUS should handle user stop as success`() {
+ whenever(constraintsChecker.applyBolusConstraints(any())).thenReturn(
+ app.aaps.core.objects.constraints.ConstraintObject(1.0, aapsLogger),
+ )
+ whenever(commandQueue.bolusInQueue()).thenReturn(false)
+ whenever(bolusProgressData.isStopPressed).thenReturn(true)
+ runTest { whenever(loop.runningMode()).thenReturn(RM.Mode.CLOSED_LOOP) }
+
+ // Mock a failure from commandQueue (which happens on stop on some pumps)
+ runTest {
+ whenever(commandQueue.bolus(any())).thenReturn(
+ pumpEnactResultProvider.get().success(false).bolusDelivered(0.5)
+ )
+ }
+ whenever(rh.gs(eq(CoreUiR.string.stop_pressed), any())).thenReturn("Stop pressed")
+
+ val result = execute(NfcCommandCode.BOLUS, JSONObject().put(NfcJsonKeys.AMOUNT, 1.0))
+
+ assertThat(result.success).isTrue()
+ assertThat(result.message).isEqualTo("Stop pressed")
+ runTest { verify(commandQueue).bolus(any()) }
+ }
+
+ @Test
+ fun `executeCommand BOLUS MEAL should enqueue bolus and set TT`() {
+ whenever(constraintsChecker.applyBolusConstraints(any())).thenReturn(
+ app.aaps.core.objects.constraints.ConstraintObject(1.0, aapsLogger),
+ )
+ whenever(commandQueue.bolusInQueue()).thenReturn(false)
+ runTest { whenever(loop.runningMode()).thenReturn(RM.Mode.CLOSED_LOOP) }
+ whenever(rh.gs(eq(R.string.nfccommands_command_executed), any())).thenReturn("Command executed")
+ whenever(preferences.get(StringNonKey.TempTargetPresets)).thenReturn("[]")
+ runTest {
+ whenever(persistenceLayer.insertAndCancelCurrentTemporaryTarget(any(), any(), any(), anyOrNull(), any()))
+ .thenReturn(PersistenceLayer.TransactionResult())
+ }
+
+ val result = execute(NfcCommandCode.BOLUS, JSONObject().put(NfcJsonKeys.AMOUNT, 1.0).put(NfcJsonKeys.IS_MEAL, true))
+
+ assertThat(result.success).isTrue()
+ runTest { verify(commandQueue).bolus(any()) }
+ runTest { verify(persistenceLayer).insertAndCancelCurrentTemporaryTarget(any(), any(), any(), anyOrNull(), any()) }
+ }
+
+ // ── processCarbs tests ─────────────────────────────────────────────────────
+
+ @Test
+ fun `executeCommand CARBS should enqueue carbs`() {
+ whenever(constraintsChecker.applyCarbsConstraints(any())).thenReturn(
+ app.aaps.core.objects.constraints.ConstraintObject(20, aapsLogger),
+ )
+ whenever(rh.gs(eq(R.string.nfccommands_carbs_set), any())).thenReturn("Carbs set")
+
+ val result = execute(NfcCommandCode.CARBS, JSONObject().put(NfcJsonKeys.AMOUNT, 20))
+
+ assertThat(result.success).isTrue()
+ runTest { verify(commandQueue).bolus(any()) }
+ }
+
+ // ── processTarget tests ────────────────────────────────────────────────────
+
+ @Test
+ fun `executeCommand TARGET_MEAL should set eating soon target`() {
+ whenever(preferences.get(StringNonKey.TempTargetPresets)).thenReturn("[]")
+ runTest {
+ whenever(persistenceLayer.insertAndCancelCurrentTemporaryTarget(any(), any(), any(), anyOrNull(), any()))
+ .thenReturn(PersistenceLayer.TransactionResult())
+ }
+ whenever(rh.gs(eq(R.string.nfccommands_tt_set), any(), any())).thenReturn("Target set")
+
+ val result = execute(NfcCommandCode.TARGET_MEAL)
+
+ assertThat(result.success).isTrue()
+ }
+
+ @Test
+ fun `executeCommand TARGET_STOP should cancel temp target`() {
+ runTest {
+ whenever(persistenceLayer.cancelCurrentTemporaryTargetIfAny(any(), any(), any(), anyOrNull(), any()))
+ .thenReturn(PersistenceLayer.TransactionResult())
+ }
+ whenever(rh.gs(R.string.nfccommands_tt_canceled)).thenReturn("TT canceled")
+ whenever(rh.gsNotLocalised(R.string.nfccommands_tt_canceled)).thenReturn("TT canceled")
+
+ val result = execute(NfcCommandCode.TARGET_STOP)
+
+ assertThat(result.success).isTrue()
+ runTest { verify(persistenceLayer).cancelCurrentTemporaryTargetIfAny(any(), eq(Action.CANCEL_TT), eq(Sources.NfcCommands), anyOrNull(), any()) }
+ }
+
+ @Test
+ fun `executeCommand TARGET_ACTIVITY should set activity target`() {
+ whenever(preferences.get(StringNonKey.TempTargetPresets)).thenReturn("[]")
+ runTest {
+ whenever(persistenceLayer.insertAndCancelCurrentTemporaryTarget(any(), any(), any(), anyOrNull(), any()))
+ .thenReturn(PersistenceLayer.TransactionResult())
+ }
+ whenever(rh.gs(eq(R.string.nfccommands_tt_set), any(), any())).thenReturn("Target set")
+
+ val result = execute(NfcCommandCode.TARGET_ACTIVITY)
+
+ assertThat(result.success).isTrue()
+ }
+
+ @Test
+ fun `executeCommand TARGET_HYPO should set hypo target`() {
+ whenever(preferences.get(StringNonKey.TempTargetPresets)).thenReturn("[]")
+ runTest {
+ whenever(persistenceLayer.insertAndCancelCurrentTemporaryTarget(any(), any(), any(), anyOrNull(), any()))
+ .thenReturn(PersistenceLayer.TransactionResult())
+ }
+ whenever(rh.gs(eq(R.string.nfccommands_tt_set), any(), any())).thenReturn("Target set")
+
+ val result = execute(NfcCommandCode.TARGET_HYPO)
+
+ assertThat(result.success).isTrue()
+ }
+
+ @Test
+ fun `executeCommand TARGET_MANUAL should set manual target`() {
+ runTest {
+ whenever(persistenceLayer.insertAndCancelCurrentTemporaryTarget(any(), any(), any(), anyOrNull(), any()))
+ .thenReturn(PersistenceLayer.TransactionResult())
+ }
+ whenever(rh.gs(eq(R.string.nfccommands_tt_set), any(), any())).thenReturn("Target set")
+
+ val result = execute(NfcCommandCode.TARGET_MANUAL, JSONObject().put(NfcJsonKeys.GLUCOSE, 100.0).put(NfcJsonKeys.DURATION, 30))
+
+ assertThat(result.success).isTrue()
+ }
+
+ @Test
+ fun `executeCommand RUN_SCENE should run scene`() {
+ runTest {
+ whenever(sceneAutomationApi.runScene(any(), anyOrNull())).thenReturn(SceneAutomationResult.Success)
+ }
+ whenever(rh.gs(CoreUiR.string.ok)).thenReturn("OK")
+
+ val result = execute(NfcCommandCode.RUN_SCENE, JSONObject().put(NfcJsonKeys.SCENE_ID, "scene1"))
+
+ assertThat(result.success).isTrue()
+ runTest { verify(sceneAutomationApi).runScene(eq("scene1"), anyOrNull()) }
+ }
+
+ @Test
+ fun `executeCommand BOLUS_WIZARD should execute wizard bolus`() {
+ val params = JSONObject()
+ .put(NfcJsonKeys.AMOUNT, 20)
+ .put(NfcJsonKeys.PERCENT, 100)
+
+ whenever(bolusWizard.insulinAfterConstraints).thenReturn(1.5)
+ plugin.setActionState(params.toString(), bolusWizard)
+ runTest { whenever(loop.runningMode()).thenReturn(RM.Mode.CLOSED_LOOP) }
+ whenever(rh.gs(eq(R.string.smscommunicator_bolus_delivered), any())).thenReturn("Bolus delivered")
+
+ val result = execute(NfcCommandCode.BOLUS_WIZARD, params)
+
+ assertThat(result.success).isTrue()
+ runTest { verify(bolusWizard).executeNormal(any(), anyOrNull(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `BolusWizardAction formatParams should perform calculation and store state`() = runTest {
+ val params = JSONObject()
+ .put(NfcJsonKeys.AMOUNT, 20)
+ .put(NfcJsonKeys.PERCENT, 100)
+ val action = plugin.getAction(NfcCommandCode.BOLUS_WIZARD)
+ action.params = params
+
+ whenever(profileFunction.getOriginalProfileName()).thenReturn("Default")
+ val cobInfo = app.aaps.core.data.iob.CobInfo(0L, 0.0, 0.0)
+ whenever(iobCobCalculator.getCobInfo(any())).thenReturn(cobInfo)
+ whenever(rh.gs(any(), any())).thenReturn("Going to deliver 1.5U")
+ whenever(rh.gs(any(), any())).thenReturn("20g carbs")
+
+ val result = action.formatParams()
+
+ assertThat(result).contains("Going to deliver")
+ verify(bolusWizard).doCalc(any(), eq("Default"), anyOrNull(), eq(20), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())
+ assertThat(plugin.getActionState(params.toString())).isEqualTo(bolusWizard)
+ }
+
+ // ── processProfile tests ───────────────────────────────────────────────────
+
+ @Test
+ fun `executeCommand PROFILE_SWITCH should create profile switch`() {
+ whenever(profileRepository.profile).thenReturn(MutableStateFlow(mockProfileStore))
+ runTest {
+ whenever(profileFunction.createProfileSwitch(any(), any(), any(), any(), any(), any(), any(), any(), anyOrNull(), any(), any()))
+ .thenReturn(mock())
+ }
+ whenever(rh.gs(R.string.nfccommands_profile_switch_created)).thenReturn("Profile switch created")
+
+ val result = execute(NfcCommandCode.PROFILE_SWITCH, JSONObject().put(NfcJsonKeys.PROFILE_NAME, "Default").put(NfcJsonKeys.PERCENT, 100))
+
+ assertThat(result.success).isTrue()
+ runTest {
+ verify(profileFunction).createProfileSwitch(
+ eq(mockProfileStore),
+ eq("Default"),
+ eq(0),
+ eq(100),
+ eq(0),
+ any(),
+ eq(Action.PROFILE_SWITCH),
+ eq(Sources.NfcCommands),
+ any(),
+ any(),
+ any(),
+ )
+ }
+ }
+
+ // ── general command tests ──────────────────────────────────────────────────
+
+ @Test
+ fun `executeCommand should fail when remote commands not allowed`() {
+ whenever(preferences.get(BooleanKey.NfcAllowRemoteCommands)).thenReturn(false)
+ whenever(rh.gs(R.string.nfccommands_remote_command_not_allowed)).thenReturn("Remote commands not allowed")
+
+ val result = execute(NfcCommandCode.LOOP_STOP)
+
+ assertThat(result.success).isFalse()
+ }
+
+ @Test
+ fun `executeCommand BOLUS MEAL should respect cooldown`() {
+ whenever(commandQueue.bolusInQueue()).thenReturn(false)
+ runTest { whenever(loop.runningMode()).thenReturn(RM.Mode.CLOSED_LOOP) }
+ whenever(rh.gs(R.string.nfccommands_remote_bolus_not_allowed)).thenReturn("Remote bolus not allowed")
+ val now = Constants.remoteBolusMinDistance * 2
+ whenever(dateUtil.now()).thenReturn(now)
+ plugin.setLastRemoteBolusTime(now)
+
+ val result = execute(NfcCommandCode.BOLUS, JSONObject().put(NfcJsonKeys.AMOUNT, 1.0).put(NfcJsonKeys.IS_MEAL, true))
+
+ assertThat(result.success).isFalse()
+ }
+
+ /* COmmand Remonved
+ @Test
+ fun `executeCommand RESTART should exit app`() {
+ whenever(rh.gs(R.string.nfccommands_restarting)).thenReturn("Restarting")
+
+ val result = execute(NfcCommandCode.RESTART)
+
+ assertThat(result.success).isTrue()
+ verify(configBuilder).exitApp(eq("NFC"), eq(Sources.NfcCommands), eq(true))
+ }
+ */
+
+ // ── executeCascade tests ───────────────────────────────────────────────────
+
+ @Test
+ fun `executeCascade all succeed returns success with combined message`() {
+ runTest { whenever(loop.allowedNextModes()).thenReturn(listOf(RM.Mode.DISABLED_LOOP)) }
+ runTest { whenever(loop.handleRunningModeChange(any(), any(), any(), any(), any(), any())).thenReturn(true) }
+ whenever(rh.gs(R.string.nfccommands_loop_has_been_disabled)).thenReturn("Loop disabled")
+ whenever(rh.gs(CoreUiR.string.stoptemptarget)).thenReturn("Temp basal canceled")
+
+ val cmd1 = NfcTagStore.buildCommand(NfcCommandCode.LOOP_STOP)
+ val cmd2 = NfcTagStore.buildCommand(NfcCommandCode.BASAL_STOP)
+ val result = cascade(listOf(cmd1, cmd2))
+
+ assertThat(result.success).isTrue()
+ assertThat(result.message).contains("Loop disabled")
+ assertThat(result.message).contains("Temp basal canceled")
+ }
+}
diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcControlActivityTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcControlActivityTest.kt
new file mode 100644
index 000000000000..76976192b8ee
--- /dev/null
+++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcControlActivityTest.kt
@@ -0,0 +1,190 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import android.content.Intent
+import android.nfc.NdefMessage
+import android.nfc.NdefRecord
+import android.nfc.NfcAdapter
+import android.nfc.Tag
+import app.aaps.core.interfaces.iob.GlucoseStatusProvider
+import app.aaps.core.interfaces.pump.BolusProgressData
+import app.aaps.core.interfaces.scenes.SceneAutomationApi
+import app.aaps.core.interfaces.scenes.SceneIconResolver
+import app.aaps.shared.tests.TestBaseWithProfile
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import java.nio.charset.StandardCharsets
+import com.google.common.truth.Truth.assertThat
+
+class NfcControlActivityTest : TestBaseWithProfile() {
+ private lateinit var pluginUnderTest: NfcCommandsPlugin
+ private lateinit var nfcTagStore: NfcTagStore
+
+ @BeforeEach
+ fun setup() {
+ nfcTagStore = NfcTagStore(TestSp())
+ nfcTagStore.clearJustWrittenForTest()
+
+ pluginUnderTest = NfcCommandsPlugin(
+ context = context,
+ aapsLogger = aapsLogger,
+ rh = rh,
+ preferences = preferences,
+ nfcTagStore = nfcTagStore,
+ constraintChecker = constraintsChecker,
+ profileFunction = profileFunction,
+ profileUtil = profileUtil,
+ profileRepository = profileRepository,
+ insulin = insulin,
+ activePlugin = activePlugin,
+ commandQueue = mock(),
+ loop = mock(),
+ dateUtil = dateUtil,
+ persistenceLayer = mock(),
+ decimalFormatter = decimalFormatter,
+ configBuilder = mock(),
+ rxBus = mock(),
+ uel = mock(),
+ bolusWizard = mock(),
+ iobCobCalculator = iobCobCalculator,
+ bolusProgressData = mock(),
+ glucoseStatusProvider = mock(),
+ sceneAutomationApi = mock(),
+ sceneIconResolver = mock(),
+ )
+ pluginUnderTest.setPluginEnabledBlocking(app.aaps.core.data.plugin.PluginType.SYNC, true)
+ whenever(rh.gs(any())).thenReturn("Mock String")
+ whenever(rh.gs(any(), any())).thenReturn("Mock String")
+ whenever(rh.gsNotLocalised(any())).thenReturn("Mock String")
+ whenever(rh.gsNotLocalised(any(), any())).thenReturn("Mock String")
+ }
+
+ private val fakeUid = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte())
+
+ private fun process(intent: Intent?) = runBlocking { pluginUnderTest.processIntent(intent) }
+
+ @Test
+ fun `processIntent prepares execution when plugin enabled and tag registered`() {
+ val uid = NfcTagStore.tagUidHex(fakeUid) ?: ""
+ nfcTagStore.saveCreatedTag(NfcCreatedTag(uid, "My Tag", listOf("{\"code\":\"LOOP_STOP\"}"), 0L))
+ val intent = createNfcIntent(mockNfcTag(fakeUid))
+
+ val result = process(intent)
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Ready::class.java)
+ val ready = result as NfcPrepareResult.Ready
+ assertThat(ready.tagUid).isEqualTo(uid)
+ }
+
+ @Test
+ fun `processIntent does nothing when plugin disabled`() {
+ pluginUnderTest.setPluginEnabledBlocking(app.aaps.core.data.plugin.PluginType.SYNC, false)
+ whenever(rh.gs(any())).thenReturn("Disabled")
+
+ val result = process(createNfcIntent(mockNfcTag(fakeUid)))
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Error::class.java)
+ }
+
+ @Test
+ fun `processIntent returns error when tag not registered`() {
+ val intent = createNfcIntent(mockNfcTag(fakeUid))
+
+ val result = process(intent)
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Error::class.java)
+ }
+
+ @Test
+ fun `processIntent returns error when intent is null`() {
+ val result = process(null)
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Error::class.java)
+ }
+
+ @Test
+ fun `processIntent returns error when intent has no NFC Tag extra`() {
+ val intent = createNfcIntent(nfcTag = null)
+
+ val result = process(intent)
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Error::class.java)
+ }
+
+ @Test
+ fun `processIntent returns error when NDEF record MIME type does not match`() {
+ val nfcTag = mockNfcTag(fakeUid)
+ val record = mock()
+ whenever(record.tnf).thenReturn(NdefRecord.TNF_MIME_MEDIA)
+ whenever(record.type).thenReturn("text/plain".toByteArray(StandardCharsets.US_ASCII))
+ whenever(record.payload).thenReturn(ByteArray(0))
+ val message = mock()
+ whenever(message.records).thenReturn(arrayOf(record))
+ val intent = mock()
+ whenever(intent.action).thenReturn(NfcAdapter.ACTION_NDEF_DISCOVERED)
+ @Suppress("DEPRECATION")
+ whenever(intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)).thenReturn(arrayOf(message))
+ @Suppress("DEPRECATION")
+ whenever(intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)).thenReturn(nfcTag)
+
+ val result = process(intent)
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Error::class.java)
+ }
+
+ @Test
+ fun `processIntent works on TAG_DISCOVERED for registered UID`() {
+ val uid = NfcTagStore.tagUidHex(fakeUid) ?: ""
+ nfcTagStore.saveCreatedTag(NfcCreatedTag(uid, "My Tag", listOf("{\"code\":\"LOOP_STOP\"}"), 0L))
+
+ val result = process(createTagDiscoveredIntent(mockNfcTag(fakeUid)))
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Ready::class.java)
+ }
+
+ @Test
+ fun `processIntent ignores just-written tag`() {
+ val uid = NfcTagStore.tagUidHex(fakeUid) ?: ""
+ nfcTagStore.markJustWritten(uid)
+
+ val result = process(createNfcIntent(mockNfcTag(fakeUid)))
+
+ assertThat(result).isInstanceOf(NfcPrepareResult.Error::class.java)
+ }
+
+ // ── helpers ────────────────────────────────────────────────────────────
+
+ private fun mockNfcTag(uid: ByteArray): Tag {
+ val tag = mock()
+ whenever(tag.id).thenReturn(uid)
+ return tag
+ }
+
+ private fun createNfcIntent(nfcTag: Tag? = mockNfcTag(fakeUid)): Intent {
+ val record = mock()
+ whenever(record.tnf).thenReturn(NdefRecord.TNF_MIME_MEDIA)
+ whenever(record.type).thenReturn(NfcTagStore.MIME_TYPE.toByteArray(StandardCharsets.US_ASCII))
+ whenever(record.payload).thenReturn(ByteArray(0))
+ val message = mock()
+ whenever(message.records).thenReturn(arrayOf(record))
+ val intent = mock()
+ whenever(intent.action).thenReturn(NfcAdapter.ACTION_NDEF_DISCOVERED)
+ @Suppress("DEPRECATION")
+ whenever(intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES))
+ .thenReturn(arrayOf(message))
+ @Suppress("DEPRECATION")
+ whenever(intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)).thenReturn(nfcTag)
+ return intent
+ }
+
+ private fun createTagDiscoveredIntent(nfcTag: Tag? = mockNfcTag(fakeUid)): Intent {
+ val intent = mock()
+ whenever(intent.action).thenReturn(NfcAdapter.ACTION_TAG_DISCOVERED)
+ @Suppress("DEPRECATION")
+ whenever(intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)).thenReturn(nfcTag)
+ return intent
+ }
+}
diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcForegroundDispatchTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcForegroundDispatchTest.kt
new file mode 100644
index 000000000000..827120c077c6
--- /dev/null
+++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcForegroundDispatchTest.kt
@@ -0,0 +1,225 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import android.app.Activity
+import android.content.Intent
+import android.nfc.NfcAdapter
+import android.nfc.NfcManager
+import app.aaps.core.interfaces.rx.events.EventShowDialog
+import app.aaps.core.keys.BooleanKey
+import app.aaps.plugins.sync.R
+import app.aaps.shared.tests.TestBaseWithProfile
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.Mock
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+class NfcForegroundDispatchTest : TestBaseWithProfile() {
+
+ @Mock lateinit var mockActivity: Activity
+ @Mock lateinit var mockRxBus: app.aaps.core.interfaces.rx.bus.RxBus
+
+ private lateinit var dispatch: NfcForegroundDispatch
+
+ @BeforeEach
+ fun setup() {
+ whenever(mockActivity.packageName).thenReturn("app.aaps")
+ dispatch = NfcForegroundDispatch(mockActivity, preferences)
+ }
+
+ // ── onResume ──────────────────────────────────────────────────────────────
+
+ @Test
+ fun `onResume does nothing when preference is off`() {
+ whenever(preferences.get(BooleanKey.NfcForegroundPriority)).thenReturn(false)
+
+ dispatch.onResume()
+
+ verify(mockActivity, never()).getSystemService(NfcManager::class.java)
+ }
+
+ @Test
+ fun `onResume does nothing when NFC manager is unavailable`() {
+ whenever(preferences.get(BooleanKey.NfcForegroundPriority)).thenReturn(true)
+ whenever(mockActivity.getSystemService(NfcManager::class.java)).thenReturn(null)
+
+ dispatch.onResume() // must not throw
+ }
+
+ @Test
+ fun `onResume does nothing when NFC adapter is null`() {
+ whenever(preferences.get(BooleanKey.NfcForegroundPriority)).thenReturn(true)
+ val nfcManager = mock()
+ whenever(mockActivity.getSystemService(NfcManager::class.java)).thenReturn(nfcManager)
+ whenever(nfcManager.defaultAdapter).thenReturn(null)
+
+ dispatch.onResume() // must not throw
+ }
+
+ @Test
+ fun `onResume enables foreground dispatch when preference on and adapter available`() {
+ whenever(preferences.get(BooleanKey.NfcForegroundPriority)).thenReturn(true)
+ val nfcManager = mock()
+ val nfcAdapter = mock()
+ whenever(mockActivity.getSystemService(NfcManager::class.java)).thenReturn(nfcManager)
+ whenever(nfcManager.defaultAdapter).thenReturn(nfcAdapter)
+
+ // PendingIntent.getActivity() is a native static — the Android stub returns null,
+ // so we verify the call happened with any PendingIntent value.
+ dispatch.onResume()
+
+ verify(nfcAdapter).enableForegroundDispatch(org.mockito.kotlin.eq(mockActivity), org.mockito.kotlin.isNull(), org.mockito.kotlin.isNull(), org.mockito.kotlin.isNull())
+ }
+
+ // ── onPause ───────────────────────────────────────────────────────────────
+
+ @Test
+ fun `onPause does nothing when dispatch was never enabled`() {
+ dispatch.onPause() // must not crash or interact with activity
+ verify(mockActivity, never()).getSystemService(NfcManager::class.java)
+ }
+
+ @Test
+ fun `onPause disables dispatch after it was enabled`() {
+ val nfcAdapter = enableDispatch()
+
+ dispatch.onPause()
+
+ verify(nfcAdapter).disableForegroundDispatch(mockActivity)
+ }
+
+ @Test
+ fun `onPause does not disable dispatch a second time`() {
+ val nfcAdapter = enableDispatch()
+
+ dispatch.onPause()
+ dispatch.onPause() // second call must not call disableForegroundDispatch again
+
+ verify(nfcAdapter, org.mockito.kotlin.times(1)).disableForegroundDispatch(mockActivity)
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ /** Drives the dispatch into the enabled state and returns the adapter mock. */
+ private fun enableDispatch(): NfcAdapter {
+ whenever(preferences.get(BooleanKey.NfcForegroundPriority)).thenReturn(true)
+ val nfcManager = mock()
+ val nfcAdapter = mock()
+ whenever(mockActivity.getSystemService(NfcManager::class.java)).thenReturn(nfcManager)
+ whenever(nfcManager.defaultAdapter).thenReturn(nfcAdapter)
+ dispatch.onResume()
+ return nfcAdapter
+ }
+
+ // ── onNewIntent ───────────────────────────────────────────────────────────
+
+ @Test
+ fun `onNewIntent forwards NDEF_DISCOVERED intent to NfcControlActivity`() {
+ val intent = mock()
+ whenever(intent.action).thenReturn(NfcAdapter.ACTION_NDEF_DISCOVERED)
+ whenever(intent.extras).thenReturn(null)
+
+ dispatch.onNewIntent(intent)
+
+ // Verify startActivity was called (Intent(Context, Class) component not inspectable in JVM stubs)
+ verify(mockActivity).startActivity(any())
+ }
+
+ @Test
+ fun `onNewIntent forwards TAG_DISCOVERED intent to NfcControlActivity`() {
+ val intent = mock()
+ whenever(intent.action).thenReturn(NfcAdapter.ACTION_TAG_DISCOVERED)
+ whenever(intent.extras).thenReturn(null)
+
+ dispatch.onNewIntent(intent)
+
+ verify(mockActivity).startActivity(any())
+ }
+
+ @Test
+ fun `onNewIntent ignores null action`() {
+ val intent = mock()
+ whenever(intent.action).thenReturn(null)
+
+ dispatch.onNewIntent(intent)
+
+ verify(mockActivity, never()).startActivity(any())
+ }
+
+ @Test
+ fun `onNewIntent ignores unrelated actions`() {
+ val intent = mock()
+ whenever(intent.action).thenReturn("android.intent.action.MAIN")
+
+ dispatch.onNewIntent(intent)
+
+ verify(mockActivity, never()).startActivity(any())
+ }
+
+ // ── observeWarning ────────────────────────────────────────────────────────
+ // Use Dispatchers.Unconfined so collectors run synchronously in the calling
+ // thread — StateFlow value changes are then visible immediately without
+ // needing advanceUntilIdle().
+
+ private fun unconfinedScope() = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
+
+ @Test
+ fun `observeWarning subscribes to NfcForegroundPriority preference`() {
+ val scope = unconfinedScope()
+ val prefFlow = MutableStateFlow(false)
+ whenever(preferences.observe(BooleanKey.NfcForegroundPriority)).thenReturn(prefFlow)
+
+ dispatch.observeWarning(scope, mockRxBus, rh)
+
+ verify(preferences).observe(BooleanKey.NfcForegroundPriority)
+ scope.cancel()
+ }
+
+ @Test
+ fun `observeWarning sends dialog when preference changes to true`() {
+ whenever(rh.gs(R.string.nfc_foreground_priority_warning_title)).thenReturn("NFC Foreground Priority")
+ whenever(rh.gs(R.string.nfc_foreground_priority_warning_message)).thenReturn("Warning message")
+ val scope = unconfinedScope()
+ val prefFlow = MutableStateFlow(false)
+ whenever(preferences.observe(BooleanKey.NfcForegroundPriority)).thenReturn(prefFlow)
+
+ dispatch.observeWarning(scope, mockRxBus, rh)
+ prefFlow.value = true // Unconfined: collector runs synchronously
+
+ verify(mockRxBus).send(any())
+ scope.cancel()
+ }
+
+ @Test
+ fun `observeWarning does not send dialog when preference changes to false`() {
+ val scope = unconfinedScope()
+ val prefFlow = MutableStateFlow(true) // initial true is the dropped first emission
+ whenever(preferences.observe(BooleanKey.NfcForegroundPriority)).thenReturn(prefFlow)
+
+ dispatch.observeWarning(scope, mockRxBus, rh)
+ prefFlow.value = false
+
+ verify(mockRxBus, never()).send(any())
+ scope.cancel()
+ }
+
+ @Test
+ fun `observeWarning does not send dialog for initial value`() {
+ val scope = unconfinedScope()
+ val prefFlow = MutableStateFlow(true) // dropped by drop(1), no further changes
+ whenever(preferences.observe(BooleanKey.NfcForegroundPriority)).thenReturn(prefFlow)
+
+ dispatch.observeWarning(scope, mockRxBus, rh)
+
+ verify(mockRxBus, never()).send(any())
+ scope.cancel()
+ }
+}
diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcTagStoreTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcTagStoreTest.kt
new file mode 100644
index 000000000000..24f3941a96ce
--- /dev/null
+++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/NfcTagStoreTest.kt
@@ -0,0 +1,324 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import app.aaps.shared.tests.SharedPreferencesMock
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+class NfcTagStoreTest {
+ private lateinit var prefs: SharedPreferencesMock
+ private lateinit var store: NfcTagStore
+ private val now = 1_700_000_000_000L
+ private val tagUid = "aabbccdd"
+
+ @BeforeEach
+ fun setup() {
+ prefs = SharedPreferencesMock()
+ store = NfcTagStore(TestSp(prefs))
+ }
+
+ private fun makeTag(
+ uid: String = "tag-uid-1",
+ name: String = "Test Tag",
+ ): NfcCreatedTag =
+ NfcCreatedTag(
+ tagUid = uid,
+ name = name,
+ commands = listOf(NfcTagStore.buildCommand(NfcCommandCode.LOOP_STOP)),
+ createdAtMillis = now,
+ )
+
+ // ── saveCreatedTag / loadCreatedTags ──────────────────────────────────────
+
+ @Test
+ fun `saveCreatedTag and loadCreatedTags round-trips correctly`() {
+ val tag = makeTag()
+ store.saveCreatedTag(tag)
+
+ val loaded = store.loadCreatedTags()
+ assertThat(loaded).hasSize(1)
+ assertThat(loaded.first().tagUid).isEqualTo(tag.tagUid)
+ assertThat(loaded.first().name).isEqualTo(tag.name)
+ assertThat(loaded.first().commands).isEqualTo(tag.commands)
+ assertThat(loaded.first().createdAtMillis).isEqualTo(tag.createdAtMillis)
+ assertThat(loaded.first().lastScannedAtMillis).isNull()
+ }
+
+ @Test
+ fun `saveCreatedTag replaces existing tag with same uid`() {
+ val original = makeTag(uid = "same-uid")
+ val updated = original.copy(name = "Updated Name")
+ store.saveCreatedTag(original)
+
+ store.saveCreatedTag(updated)
+
+ val tags = store.loadCreatedTags()
+ assertThat(tags).hasSize(1)
+ assertThat(tags.first().tagUid).isEqualTo("same-uid")
+ assertThat(tags.first().name).isEqualTo("Updated Name")
+ }
+
+ @Test
+ fun `saveCreatedTag uid matching is case-insensitive`() {
+ val original = makeTag(uid = "AABBCCDD")
+ store.saveCreatedTag(original)
+ val updated = original.copy(tagUid = "aabbccdd", name = "Lower")
+ store.saveCreatedTag(updated)
+
+ val tags = store.loadCreatedTags()
+ assertThat(tags).hasSize(1)
+ assertThat(tags.first().name).isEqualTo("Lower")
+ }
+
+ // ── updateLastScanned ─────────────────────────────────────────────────────
+
+ @Test
+ fun `updateLastScanned sets lastScannedAtMillis on matching tag`() {
+ val tag = makeTag(uid = tagUid)
+ store.saveCreatedTag(tag)
+ val scannedAt = now + 5000L
+
+ store.updateLastScanned(tagUid, scannedAt)
+
+ val loaded = store.loadCreatedTags().first()
+ assertThat(loaded.lastScannedAtMillis).isEqualTo(scannedAt)
+ }
+
+ @Test
+ fun `updateLastScanned does nothing for unknown uid`() {
+ val tag = makeTag(uid = tagUid)
+ store.saveCreatedTag(tag)
+
+ store.updateLastScanned("unknown-uid", now + 1000L)
+
+ assertThat(store.loadCreatedTags().first().lastScannedAtMillis).isNull()
+ }
+
+ @Test
+ fun `lastScannedAtMillis is null when loaded from JSON without the field`() {
+ prefs.edit().putString(
+ "nfccommunicator_created_tags_v1",
+ """[{"tagUid":"abc","name":"x","commands":["{\"code\":\"LOOP_STOP\"}"],"createdAtMillis":1000}]""",
+ ).apply()
+
+ val tag = store.loadCreatedTags().first()
+ assertThat(tag.lastScannedAtMillis).isNull()
+ }
+
+ // ── deleteCreatedTag ──────────────────────────────────────────────────────
+
+ @Test
+ fun `deleteCreatedTag removes tag from list`() {
+ val tag = makeTag(uid = "uid-to-delete")
+ store.saveCreatedTag(tag)
+ assertThat(store.loadCreatedTags()).hasSize(1)
+
+ store.deleteCreatedTag("uid-to-delete")
+
+ assertThat(store.loadCreatedTags()).isEmpty()
+ }
+
+ @Test
+ fun `deleteCreatedTag does not remove other tags`() {
+ val tag1 = makeTag(uid = "uid-1")
+ val tag2 = makeTag(uid = "uid-2")
+ store.saveCreatedTag(tag1)
+ store.saveCreatedTag(tag2)
+
+ store.deleteCreatedTag("uid-1")
+
+ val remaining = store.loadCreatedTags()
+ assertThat(remaining).hasSize(1)
+ assertThat(remaining.first().tagUid).isEqualTo("uid-2")
+ }
+
+ // ── findTagByUid ──────────────────────────────────────────────────────────
+
+ @Test
+ fun `findTagByUid returns tag when uid matches`() {
+ val tag = makeTag(uid = tagUid)
+ store.saveCreatedTag(tag)
+
+ val found = store.findTagByUid(tagUid)
+
+ assertThat(found).isNotNull()
+ assertThat(found!!.tagUid).isEqualTo(tagUid)
+ }
+
+ @Test
+ fun `findTagByUid returns null for unknown uid`() {
+ val found = store.findTagByUid("unknown-uid")
+
+ assertThat(found).isNull()
+ }
+
+ @Test
+ fun `findTagByUid is case-insensitive`() {
+ val tag = makeTag(uid = "AABBCCDD")
+ store.saveCreatedTag(tag)
+
+ val found = store.findTagByUid("aabbccdd")
+
+ assertThat(found).isNotNull()
+ }
+
+ // ── loadCreatedTags edge cases ────────────────────────────────────────────
+
+ @Test
+ fun `loadCreatedTags ignores entries without commands array`() {
+ prefs.edit().putString("nfccommunicator_created_tags_v1", """[{"tagUid":"abc","name":"x","createdAtMillis":0}]""").apply()
+
+ val tags = store.loadCreatedTags()
+
+ assertThat(tags).isEmpty()
+ }
+
+ @Test
+ fun `loadCreatedTags ignores entries with blank tagUid`() {
+ prefs.edit().putString("nfccommunicator_created_tags_v1", """[{"tagUid":"","name":"x","commands":["{\"code\":\"LOOP_STOP\"}"],"createdAtMillis":0}]""").apply()
+
+ val tags = store.loadCreatedTags()
+
+ assertThat(tags).isEmpty()
+ }
+
+ @Test
+ fun `loadCreatedTags returns empty list for malformed json`() {
+ prefs.edit().putString("nfccommunicator_created_tags_v1", "{not-valid-json").apply()
+
+ val tags = store.loadCreatedTags()
+
+ assertThat(tags).isEmpty()
+ }
+
+ @Test
+ fun `tagUidHex returns lowercase hex and null for null input`() {
+ assertThat(NfcTagStore.tagUidHex(byteArrayOf(0x0A, 0x1B, 0x2C))).isEqualTo("0a1b2c")
+ assertThat(NfcTagStore.tagUidHex(null)).isNull()
+ }
+
+ // ── markJustWritten / isJustWritten ───────────────────────────────────────
+
+ @Test
+ fun `isJustWritten returns true immediately after markJustWritten`() {
+ store.markJustWritten("uid-fresh")
+
+ assertThat(store.isJustWritten("uid-fresh")).isTrue()
+ }
+
+ @Test
+ fun `isJustWritten returns false when cooldown has expired`() {
+ store.markJustWritten("uid-expired")
+
+ // cooldownMs = -1 means any real elapsed time exceeds the window
+ assertThat(store.isJustWritten("uid-expired", cooldownMs = -1L)).isFalse()
+ }
+
+ @Test
+ fun `isJustWritten returns false for uid never written`() {
+ assertThat(store.isJustWritten("uid-never-written")).isFalse()
+ }
+
+ @Test
+ fun `isJustWritten is case-insensitive`() {
+ store.markJustWritten("CASE-WRITTEN")
+
+ assertThat(store.isJustWritten("case-written")).isTrue()
+ }
+
+ // ── log tests ─────────────────────────────────────────────────────────────
+
+ @Test
+ fun `appendLogEntry persists an entry`() {
+ val entry = NfcLogEntry(timestamp = now, tagName = "MyTag", action = "READ", success = true, message = "ok")
+ store.appendLogEntry(entry)
+
+ val loaded = store.loadLog()
+ assertThat(loaded).hasSize(1)
+ assertThat(loaded.first().tagName).isEqualTo("MyTag")
+ assertThat(loaded.first().action).isEqualTo("READ")
+ assertThat(loaded.first().success).isTrue()
+ }
+
+ @Test
+ fun `loadLog returns entries newest first`() {
+ val entry1 = NfcLogEntry(timestamp = now, tagName = "Tag1", action = "READ", success = true, message = "ok")
+ val entry2 = NfcLogEntry(timestamp = now + 1000L, tagName = "Tag2", action = "WRITE", success = true, message = "written")
+ store.appendLogEntry(entry1)
+ store.appendLogEntry(entry2)
+
+ val loaded = store.loadLog()
+ assertThat(loaded).hasSize(2)
+ assertThat(loaded[0].tagName).isEqualTo("Tag2")
+ assertThat(loaded[1].tagName).isEqualTo("Tag1")
+ }
+
+ @Test
+ fun `appendLogEntry prunes to 100 when exceeding max`() {
+ repeat(105) { i ->
+ store.appendLogEntry(NfcLogEntry(now + i, "Tag$i", "READ", true, "msg"))
+ }
+
+ val loaded = store.loadLog()
+ assertThat(loaded).hasSize(100)
+ }
+
+ @Test
+ fun `loadLog handles malformed JSON gracefully`() {
+ prefs.edit().putString("nfccommunicator_log_v1", "not-valid-json").apply()
+
+ val loaded = store.loadLog()
+ assertThat(loaded).isEmpty()
+ }
+
+ @Test
+ fun `appendLogEntry for manual execution records READ action with correct fields`() {
+ val entry = NfcLogEntry(
+ timestamp = now,
+ tagName = "Morning Routine",
+ action = "READ",
+ success = true,
+ message = "Loop disabled\nTemp basal canceled",
+ )
+ store.appendLogEntry(entry)
+
+ val loaded = store.loadLog()
+ assertThat(loaded).hasSize(1)
+ with(loaded.first()) {
+ assertThat(tagName).isEqualTo("Morning Routine")
+ assertThat(action).isEqualTo("READ")
+ assertThat(success).isTrue()
+ assertThat(message).contains("Loop disabled")
+ assertThat(message).contains("Temp basal canceled")
+ }
+ }
+
+ @Test
+ fun `appendLogEntry for failed manual execution records failure`() {
+ val entry = NfcLogEntry(
+ timestamp = now,
+ tagName = "Evening",
+ action = "READ",
+ success = false,
+ message = "Remote command not allowed",
+ )
+ store.appendLogEntry(entry)
+
+ val loaded = store.loadLog()
+ assertThat(loaded.first().success).isFalse()
+ assertThat(loaded.first().message).isEqualTo("Remote command not allowed")
+ }
+
+ @Test
+ fun `clearLog removes all entries`() {
+ store.appendLogEntry(NfcLogEntry(1L, "Tag", "READ", true, "ok"))
+ store.clearLog()
+ assertThat(store.loadLog()).isEmpty()
+ }
+
+ @Test
+ fun `clearLog on empty log is a no-op`() {
+ store.clearLog()
+ assertThat(store.loadLog()).isEmpty()
+ }
+}
diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/TestSp.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/TestSp.kt
new file mode 100644
index 000000000000..f32735175c1c
--- /dev/null
+++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/nfcCommands/TestSp.kt
@@ -0,0 +1,60 @@
+package app.aaps.plugins.sync.nfcCommands
+
+import android.content.SharedPreferences
+import app.aaps.core.interfaces.sharedPreferences.SP
+import app.aaps.shared.tests.SharedPreferencesMock
+
+/** Minimal SP backed by SharedPreferencesMock for NfcTagStore unit tests. */
+internal class TestSp(private val prefs: SharedPreferences = SharedPreferencesMock()) : SP {
+ override fun getString(key: String, defaultValue: String) = prefs.getString(key, defaultValue) ?: defaultValue
+ override fun edit(commit: Boolean, block: SP.Editor.() -> Unit) {
+ val spEditor = prefs.edit()
+ val editor = object : SP.Editor {
+ override fun clear() { spEditor.clear() }
+ override fun remove(key: String) { spEditor.remove(key) }
+ override fun remove(resourceID: Int) = throw UnsupportedOperationException()
+ override fun putBoolean(key: String, value: Boolean) { spEditor.putBoolean(key, value) }
+ override fun putBoolean(resourceID: Int, value: Boolean) = throw UnsupportedOperationException()
+ override fun putDouble(key: String, value: Double) { spEditor.putString(key, value.toString()) }
+ override fun putDouble(resourceID: Int, value: Double) = throw UnsupportedOperationException()
+ override fun putLong(key: String, value: Long) { spEditor.putLong(key, value) }
+ override fun putLong(resourceID: Int, value: Long) = throw UnsupportedOperationException()
+ override fun putInt(key: String, value: Int) { spEditor.putInt(key, value) }
+ override fun putInt(resourceID: Int, value: Int) = throw UnsupportedOperationException()
+ override fun putString(key: String, value: String) { spEditor.putString(key, value) }
+ override fun putString(resourceID: Int, value: String) = throw UnsupportedOperationException()
+ }
+ block(editor)
+ if (commit) spEditor.commit() else spEditor.apply()
+ }
+
+ override fun getAll(): Map = prefs.all
+ override fun clear() { prefs.edit().clear().apply() }
+ override fun contains(key: String) = prefs.contains(key)
+ override fun contains(resourceId: Int) = throw UnsupportedOperationException()
+ override fun remove(resourceID: Int) = throw UnsupportedOperationException()
+ override fun remove(key: String) { prefs.edit().remove(key).apply() }
+ override fun getString(resourceID: Int, defaultValue: String) = throw UnsupportedOperationException()
+ override fun getStringOrNull(resourceID: Int, defaultValue: String?) = throw UnsupportedOperationException()
+ override fun getStringOrNull(key: String, defaultValue: String?) = prefs.getString(key, defaultValue)
+ override fun getBoolean(resourceID: Int, defaultValue: Boolean) = throw UnsupportedOperationException()
+ override fun getBoolean(key: String, defaultValue: Boolean) = prefs.getBoolean(key, defaultValue)
+ override fun getDouble(resourceID: Int, defaultValue: Double) = throw UnsupportedOperationException()
+ override fun getDouble(key: String, defaultValue: Double) = prefs.getString(key, null)?.toDoubleOrNull() ?: defaultValue
+ override fun getInt(resourceID: Int, defaultValue: Int) = throw UnsupportedOperationException()
+ override fun getInt(key: String, defaultValue: Int) = prefs.getInt(key, defaultValue)
+ override fun getLong(resourceID: Int, defaultValue: Long) = throw UnsupportedOperationException()
+ override fun getLong(key: String, defaultValue: Long) = prefs.getLong(key, defaultValue)
+ override fun incLong(key: String) = throw UnsupportedOperationException()
+ override fun putBoolean(key: String, value: Boolean) { prefs.edit().putBoolean(key, value).apply() }
+ override fun putBoolean(resourceID: Int, value: Boolean) = throw UnsupportedOperationException()
+ override fun putDouble(key: String, value: Double) { prefs.edit().putString(key, value.toString()).apply() }
+ override fun putDouble(resourceID: Int, value: Double) = throw UnsupportedOperationException()
+ override fun putLong(key: String, value: Long) { prefs.edit().putLong(key, value).apply() }
+ override fun putLong(resourceID: Int, value: Long) = throw UnsupportedOperationException()
+ override fun putInt(key: String, value: Int) { prefs.edit().putInt(key, value).apply() }
+ override fun putInt(resourceID: Int, value: Int) = throw UnsupportedOperationException()
+ override fun incInt(key: String) = throw UnsupportedOperationException()
+ override fun putString(resourceID: Int, value: String) = throw UnsupportedOperationException()
+ override fun putString(key: String, value: String) { prefs.edit().putString(key, value).apply() }
+}