From 5aa040f7c0403614a8472e4e4dafc3018bc6e20c Mon Sep 17 00:00:00 2001 From: Jens Heuschkel Date: Fri, 3 Apr 2026 21:57:04 +0200 Subject: [PATCH 01/33] import NFC Plugin (cherry picked from commit a0668b94d5a3a5ce88ef3792ab02874a175eacef) --- app/build.gradle.kts | 12 +- app/src/main/AndroidManifest.xml | 22 + .../kotlin/app/aaps/core/data/ue/Sources.kt | 1 + .../app/aaps/core/interfaces/logging/LTag.kt | 3 +- .../kotlin/app/aaps/core/keys/BooleanKey.kt | 1 + core/keys/src/main/res/values/strings.xml | 1 + core/objects/src/main/res/drawable/ic_nfc.xml | 10 + .../app/aaps/database/entities/UserEntry.kt | 1 + .../converters/SourcesExtension.kt | 2 + .../aaps/plugins/main/di/NFCCommandsModule.kt | 20 + .../app/aaps/plugins/main/di/PluginsModule.kt | 3 +- .../general/nfcCommands/NfcBuildActivity.kt | 224 +++ .../general/nfcCommands/NfcBuildFragment.kt | 627 +++++++++ .../main/general/nfcCommands/NfcCategories.kt | 98 ++ .../general/nfcCommands/NfcChainAdapter.kt | 67 + .../nfcCommands/NfcCommandsFragment.kt | 350 +++++ .../general/nfcCommands/NfcCommandsPlugin.kt | 728 ++++++++++ .../general/nfcCommands/NfcControlActivity.kt | 192 +++ .../main/general/nfcCommands/NfcLogAdapter.kt | 56 + .../general/nfcCommands/NfcLogFragment.kt | 49 + .../general/nfcCommands/NfcPagerAdapter.kt | 19 + .../general/nfcCommands/NfcTagsAdapter.kt | 79 ++ .../general/nfcCommands/NfcTagsFragment.kt | 59 + .../general/nfcCommands/NfcTokenSupport.kt | 542 ++++++++ .../src/main/res/drawable/ic_info_outline.xml | 9 + .../res/layout/nfccommands_build_activity.xml | 6 + .../res/layout/nfccommands_build_fragment.xml | 793 +++++++++++ .../layout/nfccommands_cascade_step_item.xml | 47 + .../res/layout/nfccommands_command_item.xml | 29 + .../main/res/layout/nfccommands_fragment.xml | 21 + .../res/layout/nfccommands_log_fragment.xml | 40 + .../main/res/layout/nfccommands_log_item.xml | 53 + .../main/res/layout/nfccommands_tag_item.xml | 72 + .../res/layout/nfccommands_tags_fragment.xml | 40 + .../res/layout/nfccommands_write_dialog.xml | 70 + plugins/main/src/main/res/values/colors.xml | 6 + plugins/main/src/main/res/values/strings.xml | 175 +++ .../nfcCommands/NfcBuildActivityTest.kt | 122 ++ .../nfcCommands/NfcCommandsFragmentTest.kt | 69 + .../nfcCommands/NfcCommandsPluginTest.kt | 1228 +++++++++++++++++ .../nfcCommands/NfcControlActivityTest.kt | 262 ++++ .../nfcCommands/NfcTokenSupportTest.kt | 440 ++++++ 42 files changed, 6640 insertions(+), 8 deletions(-) create mode 100644 core/objects/src/main/res/drawable/ic_nfc.xml create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/di/NFCCommandsModule.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcBuildActivity.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcBuildFragment.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCategories.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcChainAdapter.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsFragment.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsPlugin.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcControlActivity.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcLogAdapter.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcLogFragment.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcPagerAdapter.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTagsAdapter.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTagsFragment.kt create mode 100644 plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTokenSupport.kt create mode 100644 plugins/main/src/main/res/drawable/ic_info_outline.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_build_activity.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_build_fragment.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_cascade_step_item.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_command_item.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_fragment.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_log_fragment.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_log_item.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_tag_item.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_tags_fragment.xml create mode 100644 plugins/main/src/main/res/layout/nfccommands_write_dialog.xml create mode 100644 plugins/main/src/main/res/values/colors.xml create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcBuildActivityTest.kt create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsFragmentTest.kt create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsPluginTest.kt create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcControlActivityTest.kt create mode 100644 plugins/main/src/test/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTokenSupportTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9074433275ce..8157c69e5c0f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -247,10 +247,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..197d2f74bcee 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + + + + + Allow remote commands via SMS Report pump unreachable via SMS Send SMS notification when pump becomes unreachable. + Allow commands via NFC 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/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/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/NFCCommandsModule.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/NFCCommandsModule.kt new file mode 100644 index 000000000000..741db040687b --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/di/NFCCommandsModule.kt @@ -0,0 +1,20 @@ +package app.aaps.plugins.main.di + +import app.aaps.plugins.main.general.nfcCommands.NfcBuildActivity +import app.aaps.plugins.main.general.nfcCommands.NfcCommandsFragment +import app.aaps.plugins.main.general.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 + + @ContributesAndroidInjector abstract fun contributesNfcBuildActivity(): NfcBuildActivity + + @ContributesAndroidInjector abstract fun contributesNfcCommandsFragment(): NfcCommandsFragment +} 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..d0381ccc6617 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 @@ -12,6 +12,7 @@ import dagger.hilt.components.SingletonComponent @Module( includes = [ PluginsModule.Bindings::class, + NFCCommandsModule::class ] ) @InstallIn(SingletonComponent::class) @@ -26,4 +27,4 @@ abstract class PluginsModule { @Binds fun bindIobCobCalculator(iobCobCalculatorPlugin: IobCobCalculatorPlugin): IobCobCalculator } -} \ No newline at end of file +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcBuildActivity.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcBuildActivity.kt new file mode 100644 index 000000000000..11eb61968022 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcBuildActivity.kt @@ -0,0 +1,224 @@ +package app.aaps.plugins.main.general.nfcCommands + +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.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.plugins.main.R +import dagger.android.AndroidInjection +import javax.inject.Inject + +/** + * Standalone Activity that owns NFC write-mode and reader-mode lifecycle. + * Hosts [NfcBuildFragment] and is launched when the user taps Add in My Tags. + */ +open class NfcBuildActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { + + @Inject lateinit var aapsLogger: AAPSLogger + + var nfcAdapter: NfcAdapter? = null + + @Volatile private var isWritingMode = false + private var pendingCommands: List = emptyList() + private var pendingTagName: String = "" + + // The physical tag currently being written; stored so issueToken can read its UID. + private var pendingTag: Tag? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + private var readerModeDisableRunnable: Runnable? = null + + companion object { + private const val POST_WRITE_READER_MODE_HOLD_MS = 3_000L + } + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidInjection.inject(this) + super.onCreate(savedInstanceState) + setContentView(R.layout.nfccommands_build_activity) + nfcAdapter = NfcAdapter.getDefaultAdapter(this) + } + + // ── Write mode lifecycle ────────────────────────────────────────────────── + + /** + * Enters write mode: shows the write dialog and enables NFC reader mode. + * Called from [NfcBuildFragment] when the user taps Write. + */ + fun startWriteMode( + commands: List, + tagName: String = "", + ) { + if (commands.isEmpty()) return + pendingCommands = commands + pendingTagName = tagName + isWritingMode = true + showWriteDialog(commands) + nfcAdapter?.enableReaderMode( + this, + this, + NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B, + null, + ) + } + + // Called on the NFC binder thread by the Android NFC stack. + override fun onTagDiscovered(tag: Tag) { + if (!isWritingMode) return + pendingTag = tag + val commands = pendingCommands + val name = pendingTagName.ifBlank { commands.firstOrNull() ?: return } + val issued = issueToken(commands) + val success = buildAndWriteNdef(tag, issued.token) + val message = buildWriteMessage(success) + persistWriteAttempt(name, success, message) + if (success) { + persistWrittenTag(issued, name, commands) + } + disableWritingMode(delayReaderModeDisable = success) + } + + /** + * Exits write mode. When [delayReaderModeDisable] is true the NFC reader mode stays + * active for [POST_WRITE_READER_MODE_HOLD_MS] to suppress immediate re-execution of + * the freshly-written tag while it is still in field. + */ + fun disableWritingMode(delayReaderModeDisable: Boolean = false) { + isWritingMode = false + if (delayReaderModeDisable) { + scheduleReaderModeDisable() + } else { + nfcAdapter?.disableReaderMode(this) + } + } + + override fun onStop() { + super.onStop() + readerModeDisableRunnable?.let { mainHandler.removeCallbacks(it) } + nfcAdapter?.disableReaderMode(this) + } + + // ── Open methods — overridable in subclasses and stubbable in unit tests ── + + /** Issues a new signed token. UID of [pendingTag] is included as the `tid` claim. */ + open fun issueToken(commands: List): NfcIssuedToken = + NfcTokenSupport.issueToken(this, commands, tagUid = NfcTokenSupport.tagUidHex(pendingTag?.id)) + + /** Writes [token] as an NDEF MIME record onto [tag]. Returns true on success. */ + open fun buildAndWriteNdef( + tag: Tag, + token: String, + ): Boolean { + val record = NdefRecord.createMime(NfcTokenSupport.MIME_TYPE, token.toByteArray()) + val message = NdefMessage(arrayOf(record)) + val ndefFormatable = NdefFormatable.get(tag) + val ndef = Ndef.get(tag) + return when { + ndefFormatable != null -> writeNdefFormatable(ndefFormatable, message) + ndef != null -> writeNdef(ndef, message) + else -> { + aapsLogger.error(LTag.NFC, "Tag supports neither Ndef nor NdefFormatable. Techs: ${tag.techList.joinToString()}") + false + } + } + } + + /** Returns the user-facing result message for a write attempt. */ + open fun buildWriteMessage(success: Boolean): String = + if (success) getString(R.string.nfccommands_tag_written) else getString(R.string.nfccommands_tag_write_error) + + /** Appends a WRITE log entry for the attempt. */ + open fun persistWriteAttempt( + name: String, + success: Boolean, + message: String, + ) { + NfcTokenSupport.appendLogEntry( + this, + NfcLogEntry( + timestamp = System.currentTimeMillis(), + tagName = name, + action = "WRITE", + success = success, + message = message, + ), + ) + } + + /** Persists the successfully written tag to SharedPreferences. */ + open fun persistWrittenTag( + token: NfcIssuedToken, + name: String, + commands: List, + ) { + NfcTokenSupport.saveCreatedTag( + this, + NfcCreatedTag( + id = token.tokenId, + name = name, + commands = commands, + token = token.token, + createdAtMillis = token.issuedAtMillis, + expiresAtMillis = token.expiresAtMillis, + ), + ) + } + + /** Shows a dialog prompting the user to hold their tag to the phone. */ + open fun showWriteDialog(commands: List) { + // Implemented in the concrete Activity; overridden/stubbed in tests. + } + + /** Schedules NFC reader mode to be disabled after [POST_WRITE_READER_MODE_HOLD_MS]. */ + open fun scheduleReaderModeDisable() { + val adapter = nfcAdapter ?: return + readerModeDisableRunnable?.let { mainHandler.removeCallbacks(it) } + val runnable = Runnable { adapter.disableReaderMode(this) } + readerModeDisableRunnable = runnable + mainHandler.postDelayed(runnable, POST_WRITE_READER_MODE_HOLD_MS) + } + + // ── Private NFC I/O helpers ─────────────────────────────────────────────── + + private fun writeNdef( + ndef: Ndef, + message: NdefMessage, + ): Boolean = + try { + ndef.connect() + try { + ndef.writeNdefMessage(message) + true + } finally { + ndef.close() + } + } catch (e: Exception) { + aapsLogger.error(LTag.NFC, "Failed to write NDEF tag", e) + false + } + + private fun writeNdefFormatable( + formatable: NdefFormatable, + message: NdefMessage, + ): Boolean = + try { + formatable.connect() + try { + formatable.format(message) + true + } finally { + formatable.close() + } + } catch (e: Exception) { + aapsLogger.error(LTag.NFC, "Failed to format and write NDEF tag", e) + false + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcBuildFragment.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcBuildFragment.kt new file mode 100644 index 000000000000..e394d2b4e6ef --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcBuildFragment.kt @@ -0,0 +1,627 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.content.Intent +import android.graphics.Typeface +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import app.aaps.core.ui.dragHelpers.SimpleItemTouchHelperCallback +import app.aaps.plugins.main.R +import app.aaps.plugins.main.databinding.NfccommandsBuildFragmentBinding +import app.aaps.plugins.main.databinding.NfccommandsCommandItemBinding +import com.google.android.material.chip.Chip + +class NfcBuildFragment : Fragment() { + private var _binding: NfccommandsBuildFragmentBinding? = null + private val binding get() = _binding!! + + private val categories = NfcCategories.build() + private val chain = mutableListOf() + private lateinit var chainAdapter: NfcChainAdapter + + private var selectedCategoryIndex = -1 + private var selectedCommandIndex = -1 + private var selectedCommand: NfcUiCommand? = null + private val commandRows = mutableListOf() + + // Arg state + private var pumpDisconnectMinutes = 30 + private var suspendMinutes = 60 + private var bolusUnits = 1.00 + private var mealBolus = false + private var basalAbsRate = 1.00 + private var basalAbsDuration = 30 + private var basalPct = 100 + private var basalPctDuration = 30 + private var extendedUnits = 1.00 + private var extendedDuration = 30 + private var carbsGrams = 20 + private var profileIndex = 1 + private var profileWithPct = false + private var profilePct = 100 + + companion object { + private const val KEY_CATEGORY = "sel_cat" + private const val KEY_COMMAND = "sel_cmd" + private const val KEY_CHAIN = "chain" + private const val KEY_PUMP_DISCONNECT = "pump_disconnect_min" + private const val KEY_SUSPEND = "suspend_min" + private const val KEY_BOLUS = "bolus_u" + private const val KEY_MEAL = "meal" + private const val KEY_BASAL_ABS = "basal_abs" + private const val KEY_BASAL_ABS_DUR = "basal_abs_dur" + private const val KEY_BASAL_PCT = "basal_pct" + private const val KEY_BASAL_PCT_DUR = "basal_pct_dur" + private const val KEY_EXT_UNITS = "ext_u" + private const val KEY_EXT_DUR = "ext_dur" + private const val KEY_CARBS = "carbs_g" + private const val KEY_PROFILE_IDX = "prof_idx" + private const val KEY_PROFILE_PCT_ON = "prof_pct_on" + private const val KEY_PROFILE_PCT = "prof_pct" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = NfccommandsBuildFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + restoreState(savedInstanceState) + // Snap saved basal durations to the connected pump's valid step size. + val step = basalDurationStep + basalAbsDuration = snapToStep(basalAbsDuration, step) + basalPctDuration = snapToStep(basalPctDuration, step) + setupCategoryChips() + setupChainRecycler() + setupDocInfoButton() + setupArgPanels() + + if (selectedCategoryIndex >= 0) { + buildCommandRows(categories[selectedCategoryIndex]) + binding.commandListCard.visibility = View.VISIBLE + } + if (selectedCommand != null) { + showArgPanel(selectedCommand!!.argType) + updatePreview() + } + updateChainVisibility() + refreshArgDisplays() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(KEY_CATEGORY, selectedCategoryIndex) + outState.putInt(KEY_COMMAND, selectedCommandIndex) + outState.putStringArrayList(KEY_CHAIN, ArrayList(chain)) + outState.putInt(KEY_PUMP_DISCONNECT, pumpDisconnectMinutes) + outState.putInt(KEY_SUSPEND, suspendMinutes) + outState.putDouble(KEY_BOLUS, bolusUnits) + outState.putBoolean(KEY_MEAL, mealBolus) + outState.putDouble(KEY_BASAL_ABS, basalAbsRate) + outState.putInt(KEY_BASAL_ABS_DUR, basalAbsDuration) + outState.putInt(KEY_BASAL_PCT, basalPct) + outState.putInt(KEY_BASAL_PCT_DUR, basalPctDuration) + outState.putDouble(KEY_EXT_UNITS, extendedUnits) + outState.putInt(KEY_EXT_DUR, extendedDuration) + outState.putInt(KEY_CARBS, carbsGrams) + outState.putInt(KEY_PROFILE_IDX, profileIndex) + outState.putBoolean(KEY_PROFILE_PCT_ON, profileWithPct) + outState.putInt(KEY_PROFILE_PCT, profilePct) + } + + private fun restoreState(savedState: Bundle?) { + if (savedState == null) return + selectedCategoryIndex = savedState.getInt(KEY_CATEGORY, -1) + selectedCommandIndex = savedState.getInt(KEY_COMMAND, -1) + val savedChain = savedState.getStringArrayList(KEY_CHAIN) + if (savedChain != null) { + chain.clear() + chain.addAll(savedChain) + } + pumpDisconnectMinutes = savedState.getInt(KEY_PUMP_DISCONNECT, 30) + suspendMinutes = savedState.getInt(KEY_SUSPEND, 60) + bolusUnits = savedState.getDouble(KEY_BOLUS, 1.00) + mealBolus = savedState.getBoolean(KEY_MEAL, false) + basalAbsRate = savedState.getDouble(KEY_BASAL_ABS, 1.00) + basalAbsDuration = savedState.getInt(KEY_BASAL_ABS_DUR, 30) + basalPct = savedState.getInt(KEY_BASAL_PCT, 100) + basalPctDuration = savedState.getInt(KEY_BASAL_PCT_DUR, 30) + extendedUnits = savedState.getDouble(KEY_EXT_UNITS, 1.00) + extendedDuration = savedState.getInt(KEY_EXT_DUR, 30) + carbsGrams = savedState.getInt(KEY_CARBS, 20) + profileIndex = savedState.getInt(KEY_PROFILE_IDX, 1) + profileWithPct = savedState.getBoolean(KEY_PROFILE_PCT_ON, false) + profilePct = savedState.getInt(KEY_PROFILE_PCT, 100) + + if (selectedCategoryIndex >= 0 && selectedCommandIndex >= 0) { + selectedCommand = categories[selectedCategoryIndex].commands.getOrNull(selectedCommandIndex) + } + } + + private fun setupCategoryChips() { + val chipGroup = binding.categoryChips + categories.forEachIndexed { index, cat -> + val chip = + Chip(requireContext()).apply { + text = getString(cat.labelResId) + isCheckable = true + isChecked = index == selectedCategoryIndex + } + chipGroup.addView(chip) + chip.setOnCheckedChangeListener { _, checked -> + if (checked) { + selectedCategoryIndex = index + selectedCommandIndex = -1 + selectedCommand = null + buildCommandRows(categories[index]) + binding.commandListCard.visibility = View.VISIBLE + hideAllArgPanels() + binding.argsCard.visibility = View.GONE + binding.previewCard.visibility = View.GONE + } + } + } + } + + private fun setupChainRecycler() { + chainAdapter = + NfcChainAdapter(chain) { pos -> + chain.removeAt(pos) + chainAdapter.notifyItemRemoved(pos) + if (pos < chain.size) chainAdapter.notifyItemRangeChanged(pos, chain.size - pos) + updateChainVisibility() + } + binding.chainRecycler.layoutManager = LinearLayoutManager(requireContext()) + binding.chainRecycler.adapter = chainAdapter + binding.chainRecycler.isNestedScrollingEnabled = false + + val callback = SimpleItemTouchHelperCallback() + val touchHelper = ItemTouchHelper(callback) + chainAdapter.touchHelper = touchHelper + touchHelper.attachToRecyclerView(binding.chainRecycler) + + binding.writeFab.setOnClickListener { + if (chain.isNotEmpty()) { + val tagName = + binding.tagName.text + ?.toString() + .orEmpty() + (parentFragment as? NfcCommandsFragment)?.startWriteMode(chain.toList(), tagName) + } + } + } + + private fun setupDocInfoButton() { + binding.docInfoButton.setOnClickListener { + val idx = selectedCategoryIndex.takeIf { it >= 0 } ?: return@setOnClickListener + val category = categories[idx] + val url = getString(R.string.nfccommands_doc_base_url) + getString(category.docAnchorResId) + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + } + + private fun setupArgPanels() { + // Pump disconnect presets + listOf( + 15 to R.string.nfccommands_duration_15m, + 30 to R.string.nfccommands_duration_30m, + 60 to R.string.nfccommands_duration_1h, + 120 to R.string.nfccommands_duration_2h, + 180 to R.string.nfccommands_duration_3h, + ).forEach { (min, labelRes) -> + val chip = + Chip(requireContext()).apply { + text = getString(labelRes) + isCheckable = false + } + chip.setOnClickListener { + pumpDisconnectMinutes = min + updatePumpDisconnectDisplay() + updatePreview() + } + binding.pumpDisconnectPresets.addView(chip) + } + + // Pump disconnect stepper + binding.pumpDisconnectMinus.setOnClickListener { + pumpDisconnectMinutes = maxOf(15, pumpDisconnectMinutes - 15) + updatePumpDisconnectDisplay() + updatePreview() + } + binding.pumpDisconnectPlus.setOnClickListener { + pumpDisconnectMinutes = minOf(180, pumpDisconnectMinutes + 15) + updatePumpDisconnectDisplay() + updatePreview() + } + + // Suspend presets + listOf( + 30 to R.string.nfccommands_duration_30m, + 60 to R.string.nfccommands_duration_1h, + 120 to R.string.nfccommands_duration_2h, + 240 to R.string.nfccommands_duration_4h, + 480 to R.string.nfccommands_duration_8h, + ).forEach { (min, labelRes) -> + val chip = + Chip(requireContext()).apply { + text = getString(labelRes) + isCheckable = false + } + chip.setOnClickListener { + suspendMinutes = min + updateSuspendDisplay() + updatePreview() + } + binding.suspendPresets.addView(chip) + } + + // Suspend stepper + binding.suspendMinus.setOnClickListener { + suspendMinutes = maxOf(30, suspendMinutes - 30) + updateSuspendDisplay() + updatePreview() + } + binding.suspendPlus.setOnClickListener { + suspendMinutes = minOf(480, suspendMinutes + 30) + updateSuspendDisplay() + updatePreview() + } + + // Bolus stepper + binding.bolusMinus.setOnClickListener { + bolusUnits = maxOf(0.05, bolusUnits - 0.05).roundTo2() + updateBolusDisplay() + updatePreview() + } + binding.bolusPlus.setOnClickListener { + bolusUnits = minOf(30.0, bolusUnits + 0.05).roundTo2() + updateBolusDisplay() + updatePreview() + } + binding.bolusMealCheck.setOnCheckedChangeListener { _, checked -> + mealBolus = checked + updatePreview() + } + + // Basal abs stepper + binding.basalAbsMinus.setOnClickListener { + basalAbsRate = maxOf(0.05, basalAbsRate - 0.05).roundTo2() + updateBasalAbsDisplay() + updatePreview() + } + binding.basalAbsPlus.setOnClickListener { + basalAbsRate = minOf(30.0, basalAbsRate + 0.05).roundTo2() + updateBasalAbsDisplay() + updatePreview() + } + binding.basalAbsDurMinus.setOnClickListener { + val s = basalDurationStep + basalAbsDuration = maxOf(s, basalAbsDuration - s) + updateBasalAbsDurDisplay() + updatePreview() + } + binding.basalAbsDurPlus.setOnClickListener { + val s = basalDurationStep + basalAbsDuration = minOf(480, basalAbsDuration + s) + updateBasalAbsDurDisplay() + updatePreview() + } + + // Basal pct stepper + binding.basalPctMinus.setOnClickListener { + basalPct = maxOf(0, basalPct - 10) + updateBasalPctDisplay() + updatePreview() + } + binding.basalPctPlus.setOnClickListener { + basalPct = minOf(200, basalPct + 10) + updateBasalPctDisplay() + updatePreview() + } + binding.basalPctDurMinus.setOnClickListener { + val s = basalDurationStep + basalPctDuration = maxOf(s, basalPctDuration - s) + updateBasalPctDurDisplay() + updatePreview() + } + binding.basalPctDurPlus.setOnClickListener { + val s = basalDurationStep + basalPctDuration = minOf(480, basalPctDuration + s) + updateBasalPctDurDisplay() + updatePreview() + } + + // Extended stepper + binding.extUnitsMinus.setOnClickListener { + extendedUnits = maxOf(0.05, extendedUnits - 0.05).roundTo2() + updateExtDisplay() + updatePreview() + } + binding.extUnitsPlus.setOnClickListener { + extendedUnits = minOf(30.0, extendedUnits + 0.05).roundTo2() + updateExtDisplay() + updatePreview() + } + binding.extDurMinus.setOnClickListener { + extendedDuration = maxOf(15, extendedDuration - 15) + updateExtDurDisplay() + updatePreview() + } + binding.extDurPlus.setOnClickListener { + extendedDuration = minOf(480, extendedDuration + 15) + updateExtDurDisplay() + updatePreview() + } + + // Carbs stepper + binding.carbsMinus.setOnClickListener { + carbsGrams = maxOf(5, carbsGrams - 5) + updateCarbsDisplay() + updatePreview() + } + binding.carbsPlus.setOnClickListener { + carbsGrams = minOf(500, carbsGrams + 5) + updateCarbsDisplay() + updatePreview() + } + + // Profile stepper + binding.profileIndexMinus.setOnClickListener { + profileIndex = maxOf(1, profileIndex - 1) + updateProfileDisplay() + updatePreview() + } + binding.profileIndexPlus.setOnClickListener { + profileIndex = minOf(20, profileIndex + 1) + updateProfileDisplay() + updatePreview() + } + binding.profileWithPct.setOnCheckedChangeListener { _, checked -> + profileWithPct = checked + binding.profilePctRow.visibility = if (checked) View.VISIBLE else View.GONE + updatePreview() + } + binding.profilePctMinus.setOnClickListener { + profilePct = maxOf(70, profilePct - 5) + updateProfilePctDisplay() + updatePreview() + } + binding.profilePctPlus.setOnClickListener { + profilePct = minOf(200, profilePct + 5) + updateProfilePctDisplay() + updatePreview() + } + + // Add to chain + binding.addToChain.setOnClickListener { + val cmd = + binding.previewText.text + ?.toString() + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: return@setOnClickListener + chain.add(cmd) + chainAdapter.notifyItemInserted(chain.size - 1) + updateChainVisibility() + } + } + + private fun buildCommandRows(category: NfcUiCategory) { + binding.commandList.removeAllViews() + commandRows.clear() + + category.commands.forEachIndexed { index, command -> + val rowBinding = NfccommandsCommandItemBinding.inflate(layoutInflater, binding.commandList, false) + rowBinding.commandLabel.text = getString(command.displayLabelResId) + if (index == selectedCommandIndex) { + rowBinding.commandLabel.setTypeface(null, Typeface.BOLD) + rowBinding.commandSelectedIcon.visibility = View.VISIBLE + } + rowBinding.root.setOnClickListener { + onCommandRowSelected(index, command, category) + } + commandRows.add(rowBinding.root) + binding.commandList.addView(rowBinding.root) + } + } + + private fun onCommandRowSelected( + index: Int, + command: NfcUiCommand, + category: NfcUiCategory, + ) { + selectedCommandIndex = index + selectedCommand = command + + // Update row visuals + commandRows.forEachIndexed { i, row -> + val label = row.findViewById(R.id.command_label) + val icon = row.findViewById(R.id.command_selected_icon) + label.setTypeface(null, if (i == index) Typeface.BOLD else Typeface.NORMAL) + icon.visibility = if (i == index) View.VISIBLE else View.GONE + } + + showArgPanel(command.argType) + updatePreview() + } + + private fun showArgPanel(argType: ArgType) { + hideAllArgPanels() + binding.argsCard.visibility = View.VISIBLE + when (argType) { + ArgType.NONE -> binding.panelNone.visibility = View.VISIBLE + ArgType.SUSPEND -> binding.panelSuspend.visibility = View.VISIBLE + ArgType.PUMP_DISCONNECT -> binding.panelPumpDisconnect.visibility = View.VISIBLE + ArgType.BOLUS -> binding.panelBolus.visibility = View.VISIBLE + ArgType.BASAL_ABS -> binding.panelBasalAbs.visibility = View.VISIBLE + ArgType.BASAL_PCT -> binding.panelBasalPct.visibility = View.VISIBLE + ArgType.EXTENDED -> binding.panelExtended.visibility = View.VISIBLE + ArgType.CARBS -> binding.panelCarbs.visibility = View.VISIBLE + ArgType.PROFILE -> binding.panelProfile.visibility = View.VISIBLE + } + refreshArgDisplays() + } + + private fun hideAllArgPanels() { + binding.panelNone.visibility = View.GONE + binding.panelSuspend.visibility = View.GONE + binding.panelPumpDisconnect.visibility = View.GONE + binding.panelBolus.visibility = View.GONE + binding.panelBasalAbs.visibility = View.GONE + binding.panelBasalPct.visibility = View.GONE + binding.panelExtended.visibility = View.GONE + binding.panelCarbs.visibility = View.GONE + binding.panelProfile.visibility = View.GONE + } + + private fun updatePreview() { + val cmd = buildCurrentCommand() + if (cmd != null) { + binding.previewText.setText(cmd) + binding.previewCard.visibility = View.VISIBLE + } else { + binding.previewCard.visibility = View.GONE + } + } + + private fun buildCurrentCommand(): String? { + val command = selectedCommand ?: return null + return when (command.argType) { + ArgType.NONE -> NfcTokenSupport.buildCommand(command.template, "") + ArgType.SUSPEND -> NfcTokenSupport.buildCommand(command.template, suspendMinutes.toString()) + ArgType.PUMP_DISCONNECT -> NfcTokenSupport.buildCommand(command.template, pumpDisconnectMinutes.toString()) + ArgType.BOLUS -> { + val args = if (mealBolus) "%.2f MEAL".format(bolusUnits) else "%.2f".format(bolusUnits) + NfcTokenSupport.buildCommand(command.template, args) + } + ArgType.BASAL_ABS -> NfcTokenSupport.buildCommand(command.template, "%.2f %d".format(basalAbsRate, basalAbsDuration)) + ArgType.BASAL_PCT -> NfcTokenSupport.buildCommand(command.template, "$basalPct% $basalPctDuration") + ArgType.EXTENDED -> NfcTokenSupport.buildCommand(command.template, "%.2f %d".format(extendedUnits, extendedDuration)) + ArgType.CARBS -> NfcTokenSupport.buildCommand(command.template, carbsGrams.toString()) + ArgType.PROFILE -> { + val args = if (profileWithPct) "$profileIndex $profilePct" else profileIndex.toString() + NfcTokenSupport.buildCommand(command.template, args) + } + } + } + + private fun updateChainVisibility() { + val hasChain = chain.isNotEmpty() + binding.chainTitle.visibility = if (hasChain) View.VISIBLE else View.GONE + binding.chainRecycler.visibility = if (hasChain) View.VISIBLE else View.GONE + binding.writeFab.visibility = if (hasChain) View.VISIBLE else View.GONE + } + + fun clearChain() { + chain.clear() + chainAdapter.notifyDataSetChanged() + updateChainVisibility() + } + + // Display refresh helpers + private fun refreshArgDisplays() { + updatePumpDisconnectDisplay() + updateSuspendDisplay() + updateBolusDisplay() + updateBasalAbsDisplay() + updateBasalAbsDurDisplay() + updateBasalPctDisplay() + updateBasalPctDurDisplay() + updateExtDisplay() + updateExtDurDisplay() + updateCarbsDisplay() + updateProfileDisplay() + updateProfilePctDisplay() + + binding.bolusMealCheck.isChecked = mealBolus + binding.profileWithPct.isChecked = profileWithPct + binding.profilePctRow.visibility = if (profileWithPct) View.VISIBLE else View.GONE + } + + private fun updatePumpDisconnectDisplay() { + binding.pumpDisconnectValue.text = getString(R.string.nfccommands_pump_disconnect_minutes, pumpDisconnectMinutes) + } + + private fun updateSuspendDisplay() { + binding.suspendValue.text = getString(R.string.nfccommands_suspend_minutes, suspendMinutes) + } + + private fun updateBolusDisplay() { + binding.bolusValue.text = getString(R.string.nfccommands_bolus_units, bolusUnits) + } + + private fun updateBasalAbsDisplay() { + binding.basalAbsValue.text = getString(R.string.nfccommands_basal_abs_value).format(basalAbsRate) + } + + private fun updateBasalAbsDurDisplay() { + binding.basalAbsDurValue.text = basalAbsDuration.toString() + } + + private fun updateBasalPctDisplay() { + binding.basalPctValue.text = getString(R.string.nfccommands_percent_value, basalPct) + } + + private fun updateBasalPctDurDisplay() { + binding.basalPctDurValue.text = basalPctDuration.toString() + } + + private fun updateExtDisplay() { + binding.extUnitsValue.text = getString(R.string.nfccommands_bolus_units).format(extendedUnits) + } + + private fun updateExtDurDisplay() { + binding.extDurValue.text = extendedDuration.toString() + } + + private fun updateCarbsDisplay() { + binding.carbsValue.text = getString(R.string.nfccommands_carbs_value, carbsGrams) + } + + private fun updateProfileDisplay() { + binding.profileIndexValue.text = profileIndex.toString() + } + + private fun updateProfilePctDisplay() { + binding.profilePctValue.text = getString(R.string.nfccommands_percent_value, profilePct) + } + + private fun Double.roundTo2() = Math.round(this * 100) / 100.0 + + /** + * Returns the active pump's TBR duration step in minutes. + * Falls back to 60 if the parent is not yet attached or the pump is unknown. + */ + private val basalDurationStep: Int + get() = (parentFragment as? NfcCommandsFragment)?.pumpBasalDurationStep() ?: 60 + + /** + * Rounds [value] up to the nearest positive multiple of [step], + * with a minimum of [step] itself. + */ + private fun snapToStep( + value: Int, + step: Int, + ): Int = if (value % step == 0) maxOf(step, value) else maxOf(step, ((value / step) + 1) * step) + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCategories.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCategories.kt new file mode 100644 index 000000000000..78f6a41081dd --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCategories.kt @@ -0,0 +1,98 @@ +package app.aaps.plugins.main.general.nfcCommands + +import app.aaps.plugins.main.R + +enum class ArgType { NONE, SUSPEND, PUMP_DISCONNECT, BOLUS, BASAL_ABS, BASAL_PCT, EXTENDED, CARBS, PROFILE } + +data class NfcUiCategory( + val labelResId: Int, + val commands: List, + val docAnchorResId: Int, +) + +data class NfcUiCommand( + val template: NfcCommandTemplate, + val argType: ArgType, + val displayLabelResId: Int = template.labelResId, +) + +object NfcCategories { + fun build(): List { + val templates = NfcTokenSupport.availableCommands() + + fun byLabelResId(resId: Int) = templates.first { it.labelResId == resId } + + return listOf( + NfcUiCategory( + labelResId = R.string.nfccommands_cat_loop, + commands = + listOf( + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_loop_stop), ArgType.NONE), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_loop_resume), ArgType.NONE), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_loop_suspend), ArgType.SUSPEND), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_loop_closed), ArgType.NONE), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_loop_lgs), ArgType.NONE), + ), + docAnchorResId = R.string.nfccommands_doc_anchor_loop, + ), + NfcUiCategory( + labelResId = R.string.nfccommands_cat_pump, + commands = + listOf( + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_pump_connect), ArgType.NONE), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_pump_disconnect), ArgType.PUMP_DISCONNECT), + ), + docAnchorResId = R.string.nfccommands_doc_anchor_pump, + ), + NfcUiCategory( + labelResId = R.string.nfccommands_cat_basal, + commands = + listOf( + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_basal_absolute), ArgType.BASAL_ABS, R.string.nfccommands_cmd_temp_basal_absolute), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_basal_percent), ArgType.BASAL_PCT, R.string.nfccommands_cmd_temp_basal_percent), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_basal_stop), ArgType.NONE, R.string.nfccommands_cmd_temp_basal_stop), + ), + docAnchorResId = R.string.nfccommands_doc_anchor_basal, + ), + NfcUiCategory( + labelResId = R.string.nfccommands_cat_bolus, + commands = + listOf( + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_bolus), ArgType.BOLUS), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_extended_bolus), ArgType.EXTENDED), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_extended_stop), ArgType.NONE), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_carbs), ArgType.CARBS), + ), + docAnchorResId = R.string.nfccommands_doc_anchor_bolus, + ), + NfcUiCategory( + labelResId = R.string.nfccommands_cat_profile, + commands = + listOf( + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_profile_switch), ArgType.PROFILE), + ), + docAnchorResId = R.string.nfccommands_doc_anchor_profile, + ), + NfcUiCategory( + labelResId = R.string.nfccommands_cat_targets, + commands = + listOf( + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_target_meal), ArgType.NONE), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_target_activity), ArgType.NONE), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_target_hypo), ArgType.NONE), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_target_stop), ArgType.NONE), + ), + docAnchorResId = R.string.nfccommands_doc_anchor_targets, + ), + NfcUiCategory( + labelResId = R.string.nfccommands_cat_system, + commands = + listOf( + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_aapsclient_restart), ArgType.NONE), + NfcUiCommand(byLabelResId(R.string.nfccommands_cmd_restart_aaps), ArgType.NONE), + ), + docAnchorResId = R.string.nfccommands_doc_anchor_system, + ), + ) + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcChainAdapter.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcChainAdapter.kt new file mode 100644 index 000000000000..eb525fad2f6d --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcChainAdapter.kt @@ -0,0 +1,67 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import app.aaps.core.ui.dragHelpers.ItemTouchHelperAdapter +import app.aaps.plugins.main.R +import app.aaps.plugins.main.databinding.NfccommandsCascadeStepItemBinding + +class NfcChainAdapter( + private val chain: MutableList, + private val onRemove: (Int) -> Unit, +) : RecyclerView.Adapter(), + ItemTouchHelperAdapter { + var touchHelper: ItemTouchHelper? = null + + inner class ViewHolder( + val binding: NfccommandsCascadeStepItemBinding, + ) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val binding = NfccommandsCascadeStepItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + val ctx = holder.itemView.context + holder.binding.stepLabel.text = ctx.getString(R.string.nfccommands_cascade_step_label, position + 1, chain[position]) + + holder.binding.stepRemoveButton.setOnClickListener { + val pos = holder.bindingAdapterPosition + if (pos != RecyclerView.NO_ID.toInt()) onRemove(pos) + } + + @Suppress("ClickableViewAccessibility") + holder.binding.dragHandle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + touchHelper?.startDrag(holder) + } + false + } + } + + override fun getItemCount() = chain.size + + override fun onItemMove( + fromPosition: Int, + toPosition: Int, + ): Boolean { + val item = chain.removeAt(fromPosition) + chain.add(toPosition, item) + notifyItemMoved(fromPosition, toPosition) + return true + } + + override fun onDrop() { + notifyDataSetChanged() + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsFragment.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsFragment.kt new file mode 100644 index 000000000000..1f9fe444ee37 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsFragment.kt @@ -0,0 +1,350 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +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.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import app.aaps.core.interfaces.logging.LTag +import app.aaps.plugins.main.R +import app.aaps.plugins.main.databinding.NfccommandsFragmentBinding +import app.aaps.plugins.main.databinding.NfccommandsWriteDialogBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayoutMediator +import dagger.android.support.DaggerFragment + +class NfcCommandsFragment : DaggerFragment() { + @javax.inject.Inject lateinit var plugin: NfcCommandsPlugin + + private var bindingOrNull: NfccommandsFragmentBinding? = null + private val binding get() = bindingOrNull!! + + private var nfcAdapter: NfcAdapter? = null + private lateinit var pagerAdapter: NfcPagerAdapter + + @Volatile private var isWritingMode = false + private var pendingCommands = emptyList() + private var pendingTagName = "" + private var writeDialog: AlertDialog? = null + private var pulseAnimSet: AnimatorSet? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var readerModeDisableRunnable: Runnable? = null + + companion object { + // After a successful write the tag is still physically in range. Keeping reader + // mode active for this window suppresses ACTION_NDEF_DISCOVERED (which would + // otherwise execute the freshly-written commands immediately). By the time this + // expires the user will have moved the phone away. + private const val POST_WRITE_READER_MODE_HOLD_MS = 3_000L + } + + /** Returns the active pump's TBR duration step in minutes (e.g. 30 or 60). */ + fun pumpBasalDurationStep(): Int = plugin.pumpBasalDurationStep() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + bindingOrNull = NfccommandsFragmentBinding.inflate(inflater, container, false) + nfcAdapter = NfcAdapter.getDefaultAdapter(requireContext()) + + pagerAdapter = NfcPagerAdapter(this) + binding.viewPager.adapter = pagerAdapter + + TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> + tab.text = + when (position) { + 0 -> getString(R.string.nfccommands_tab_build) + else -> getString(R.string.nfccommands_tab_my_tags) + } + }.attach() + + return binding.root + } + + fun startWriteMode( + commands: List, + tagName: String = "", + ) { + if (commands.isEmpty()) return + val adapter = nfcAdapter + if (adapter == null) { + Toast.makeText(requireContext(), getString(R.string.nfccommands_nfc_not_supported), Toast.LENGTH_SHORT).show() + return + } + pendingCommands = commands + pendingTagName = tagName + isWritingMode = true + + showWriteDialog(commands) + + adapter.enableReaderMode( + requireActivity(), + { tag -> onTagDiscovered(tag) }, + NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B, + null, + ) + } + + private fun showWriteDialog(commands: List) { + val dialogView = NfccommandsWriteDialogBinding.inflate(layoutInflater) + + // Set up circular ring backgrounds using primary color + val primaryColor = + TypedValue() + .also { + requireContext().theme.resolveAttribute(android.R.attr.colorPrimary, it, true) + }.data + + dialogView.ring1.background = + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(primaryColor) + } + dialogView.ring2.background = + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(Color.TRANSPARENT) + setStroke(dpToPx(3), primaryColor) + } + dialogView.ring3.background = + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(Color.TRANSPARENT) + setStroke(dpToPx(2), primaryColor) + } + + dialogView.writeSummary.text = + commands + .mapIndexed { i, cmd -> + getString(R.string.nfccommands_cascade_step_label, i + 1, cmd) + }.joinToString("\n") + + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setView(dialogView.root) + .setCancelable(false) + .create() + + dialogView.cancelButton.setOnClickListener { + disableWritingMode() + } + + dialog.show() + writeDialog = dialog + + // Pulse animation + startPulseAnimation(dialogView) + } + + private fun startPulseAnimation(dialogView: NfccommandsWriteDialogBinding) { + fun pulseAnim( + view: View, + startDelay: Long, + ): AnimatorSet { + val scaleX = + ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.3f).apply { + duration = 900 + repeatMode = ValueAnimator.RESTART + repeatCount = ValueAnimator.INFINITE + this.startDelay = startDelay + } + val scaleY = + ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.3f).apply { + duration = 900 + repeatMode = ValueAnimator.RESTART + repeatCount = ValueAnimator.INFINITE + this.startDelay = startDelay + } + val alpha = + ObjectAnimator.ofFloat(view, "alpha", view.alpha, 0f).apply { + duration = 900 + repeatMode = ValueAnimator.RESTART + repeatCount = ValueAnimator.INFINITE + this.startDelay = startDelay + } + return AnimatorSet().also { it.playTogether(scaleX, scaleY, alpha) } + } + + val animSet = AnimatorSet() + animSet.playTogether( + pulseAnim(dialogView.ring1, 0), + pulseAnim(dialogView.ring2, 200), + pulseAnim(dialogView.ring3, 400), + ) + animSet.start() + pulseAnimSet = animSet + } + + private fun dpToPx(dp: Int): Int = (dp * resources.displayMetrics.density).toInt() + + // Called on the NFC binder thread — do not move to UI thread before the I/O is done. + fun onTagDiscovered(tag: Tag) { + if (!isWritingMode) return + writeTag(tag) + } + + // Runs on the NFC binder thread so that blocking I/O does not stall the main thread. + // UI updates are posted back via requireActivity().runOnUiThread. + private fun writeTag(tag: Tag) { + val commands = pendingCommands + val tagName = pendingTagName + if (commands.isEmpty()) { + requireActivity().runOnUiThread { disableWritingMode() } + return + } + val name = tagName.ifBlank { commands.first() } + val tagUid = NfcTokenSupport.tagUidHex(tag.id) + val issued = NfcTokenSupport.issueToken(requireContext(), commands, tagUid = tagUid) + val record = NdefRecord.createMime(NfcTokenSupport.MIME_TYPE, issued.token.toByteArray()) + val message = NdefMessage(arrayOf(record)) + + val ndefFormatable = NdefFormatable.get(tag) + val ndef = Ndef.get(tag) + val success = + when { + ndefFormatable != null -> writeNdefFormatable(ndefFormatable, message) + ndef != null -> writeNdef(ndef, message) + else -> { + Log.e(LTag.NFC.tag, "Tag supports neither Ndef nor NdefFormatable. Supported techs: ${tag.techList.joinToString()}") + false + } + } + + if (success) { + NfcTokenSupport.saveCreatedTag( + requireContext(), + NfcCreatedTag( + id = issued.tokenId, + name = name, + commands = commands, + token = issued.token, + createdAtMillis = issued.issuedAtMillis, + expiresAtMillis = issued.expiresAtMillis, + ), + ) + requireActivity().runOnUiThread { + pagerAdapter.tagsFragment.refresh() + pagerAdapter.buildFragment.clearChain() + Toast.makeText(requireContext(), getString(R.string.nfccommands_tag_written), Toast.LENGTH_SHORT).show() + // Delay disableReaderMode so that the tag still in the field cannot + // trigger ACTION_NDEF_DISCOVERED immediately after writing. + disableWritingMode(delayReaderModeDisable = true) + } + } else { + requireActivity().runOnUiThread { + Toast.makeText(requireContext(), getString(R.string.nfccommands_tag_write_error), Toast.LENGTH_SHORT).show() + disableWritingMode() + } + } + } + + private fun writeNdef( + ndef: Ndef, + message: NdefMessage, + ): Boolean = + try { + ndef.connect() + try { + ndef.writeNdefMessage(message) + true + } finally { + ndef.close() + } + } catch (e: Exception) { + Log.e(LTag.NFC.tag, "Failed to write NDEF tag", e) + false + } + + private fun writeNdefFormatable( + formatable: NdefFormatable, + message: NdefMessage, + ): Boolean = + try { + formatable.connect() + try { + formatable.format(message) + true + } finally { + formatable.close() + } + } catch (e: Exception) { + Log.e(LTag.NFC.tag, "Failed to format and write NDEF tag", e) + false + } + + // Disables write mode. When delayReaderModeDisable=true the NFC reader mode stays + // active for POST_WRITE_READER_MODE_HOLD_MS after a successful write; reader mode + // suppresses ACTION_NDEF_DISCOVERED so the freshly-written tag cannot auto-execute + // while it is still in the field. The normal (immediate) path is used for cancels, + // errors and fragment lifecycle stops. + private fun disableWritingMode(delayReaderModeDisable: Boolean = false) { + isWritingMode = false + pulseAnimSet?.cancel() + pulseAnimSet = null + writeDialog?.dismiss() + writeDialog = null + if (delayReaderModeDisable) { + scheduleReaderModeDisable() + } else { + cancelScheduledReaderModeDisable() + nfcAdapter?.disableReaderMode(requireActivity()) + } + } + + private fun scheduleReaderModeDisable() { + cancelScheduledReaderModeDisable() + val adapter = nfcAdapter ?: return + val runnable = + Runnable { + if (isAdded) adapter.disableReaderMode(requireActivity()) + } + readerModeDisableRunnable = runnable + mainHandler.postDelayed(runnable, POST_WRITE_READER_MODE_HOLD_MS) + } + + private fun cancelScheduledReaderModeDisable() { + readerModeDisableRunnable?.let { mainHandler.removeCallbacks(it) } + readerModeDisableRunnable = null + } + + override fun onStop() { + super.onStop() + // Cancel any delayed disable and ensure reader mode is off immediately. + // This covers both: active write mode and the post-write hold window. + cancelScheduledReaderModeDisable() + if (isWritingMode) { + isWritingMode = false + pulseAnimSet?.cancel() + pulseAnimSet = null + writeDialog?.dismiss() + writeDialog = null + } + nfcAdapter?.disableReaderMode(requireActivity()) + } + + override fun onDestroyView() { + super.onDestroyView() + cancelScheduledReaderModeDisable() + pulseAnimSet?.cancel() + writeDialog?.dismiss() + bindingOrNull = null + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsPlugin.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsPlugin.kt new file mode 100644 index 000000000000..71f7c158e855 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcCommandsPlugin.kt @@ -0,0 +1,728 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.content.Context +import android.widget.Toast +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceManager +import androidx.preference.PreferenceScreen +import app.aaps.core.data.configuration.Constants +import app.aaps.core.data.model.GlucoseUnit +import app.aaps.core.data.model.RM +import app.aaps.core.data.model.TT +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.data.ue.ValueWithUnit +import app.aaps.core.interfaces.aps.Loop +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.insulin.Insulin +import app.aaps.core.interfaces.plugin.ActivePlugin +import app.aaps.core.interfaces.plugin.PluginBase +import app.aaps.core.interfaces.plugin.PluginDescription +import app.aaps.core.interfaces.profile.LocalProfileManager +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.profile.ProfileUtil +import app.aaps.core.interfaces.pump.DetailedBolusInfo +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.Callback +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.rx.events.EventNSClientRestart +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.DecimalFormatter +import app.aaps.core.interfaces.utils.SafeParse +import app.aaps.core.keys.BooleanKey +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.UnitDoubleKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.objects.constraints.ConstraintObject +import app.aaps.core.validators.preferences.AdaptiveSwitchPreference +import app.aaps.plugins.main.R +import kotlinx.coroutines.runBlocking +import org.apache.commons.lang3.Strings +import java.util.Locale +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +sealed class NfcPrepareResult { + data class Error( + val message: String, + val eraseTag: Boolean = false, + ) : NfcPrepareResult() + + data class Ready( + val tokenId: String, + val commands: List, + val rewriteWith: NfcIssuedToken?, // null = no rewrite needed + val oldTag: NfcCreatedTag?, // non-null iff rewriteWith != null + ) : NfcPrepareResult() { + init { + require((rewriteWith == null) == (oldTag == null)) { + "rewriteWith and oldTag must both be null or both be non-null" + } + } + } +} + +data class NfcExecutionResult( + val success: Boolean, + val message: String, + val eraseTag: Boolean = false, +) + +@Singleton +class NfcCommandsPlugin + @Inject + constructor( + private val context: Context, + aapsLogger: AAPSLogger, + rh: ResourceHelper, + private val preferences: Preferences, + private val constraintChecker: ConstraintsChecker, + private val profileFunction: ProfileFunction, + private val profileUtil: ProfileUtil, + private val localProfileManager: LocalProfileManager, + private val insulin: Insulin, + private val activePlugin: ActivePlugin, + private val commandQueue: CommandQueue, + private val loop: Loop, + private val dateUtil: DateUtil, + private val persistenceLayer: PersistenceLayer, + private val decimalFormatter: DecimalFormatter, + private val configBuilder: ConfigBuilder, + private val rxBus: RxBus, + ) : PluginBase( + PluginDescription() + .mainType(PluginType.GENERAL) + .fragmentClass(NfcCommandsFragment::class.java.name) + .pluginIcon(app.aaps.core.objects.R.drawable.ic_nfc) + .pluginName(R.string.nfccommands) + .shortName(R.string.nfccommands_shortname) + .preferencesId(PluginDescription.PREFERENCE_SCREEN) + .description(R.string.description_nfc_communicator), + aapsLogger, + rh, + ) { + @Volatile private var lastRemoteBolusTime: Long = 0 + + override fun addPreferenceScreen( + preferenceManager: PreferenceManager, + parent: PreferenceScreen, + context: Context, + requiredKey: String?, + ) { + if (requiredKey != null) return + val category = PreferenceCategory(context) + parent.addPreference(category) + category.apply { + key = "nfccommunicator_settings" + title = rh.gs(R.string.nfccommands) + addPreference( + AdaptiveSwitchPreference( + ctx = context, + booleanKey = BooleanKey.NfcAllowRemoteCommands, + title = R.string.nfccommands_remote_commands_allowed, + ), + ) + addPreference( + Preference(context).apply { + key = "nfccommunicator_clear_blacklist" + title = rh.gs(R.string.nfccommands_clear_blacklist) + summary = rh.gs(R.string.nfccommands_clear_blacklist_summary) + setOnPreferenceClickListener { + NfcTokenSupport.clearBlacklist(context) + Toast.makeText(context, rh.gs(R.string.nfccommands_blacklist_cleared), Toast.LENGTH_SHORT).show() + true + } + }, + ) + } + } + + fun prepareExecution(token: String, tagUid: String? = null): NfcPrepareResult { + if (!isEnabled()) { + return NfcPrepareResult.Error(rh.gs(R.string.nfccommands_plugin_disabled)) + } + val now = dateUtil.now() + return when (val verified = NfcTokenSupport.verifyToken(context, token, now, tagUid)) { + is NfcTokenVerificationResult.Failure -> + NfcPrepareResult.Error(verified.reason) + is NfcTokenVerificationResult.Success -> { + if (NfcTokenSupport.isBlacklisted(context, verified.tokenId, now)) { + aapsLogger.debug(LTag.NFC, "Scanned token is blacklisted: ${verified.tokenId}") + return NfcPrepareResult.Error( + message = rh.gs(R.string.nfccommands_tag_erased_blacklisted), + eraseTag = true, + ) + } + val foundTag = + NfcTokenSupport + .loadCreatedTags(context) + .find { it.id == verified.tokenId } + if (foundTag != null && foundTag.isExpiringSoon(now)) { + val newToken = NfcTokenSupport.issueToken(context, verified.commands, tagUid = tagUid) + NfcPrepareResult.Ready( + tokenId = verified.tokenId, + commands = verified.commands, + rewriteWith = newToken, + oldTag = foundTag, + ) + } else { + NfcPrepareResult.Ready( + tokenId = verified.tokenId, + commands = verified.commands, + rewriteWith = null, + oldTag = null, + ) + } + } + } + } + + fun executeToken(token: String): NfcExecutionResult = + when (val prep = prepareExecution(token)) { + is NfcPrepareResult.Error -> + NfcExecutionResult(success = false, message = prep.message, eraseTag = prep.eraseTag) + is NfcPrepareResult.Ready -> + executeCascade(prep.commands) + } + + fun replaceTag( + oldId: String, + newTag: NfcCreatedTag, + ) { + NfcTokenSupport.replaceTag(context, oldId, newTag) + } + + 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) + } + + fun pumpBasalDurationStep(): Int = + activePlugin.activePump + .model() + .tbrSettings() + ?.durationStep ?: 60 + + fun executeCommand(command: String): NfcExecutionResult { + aapsLogger.debug(LTag.NFC, "Executing NFC command: $command") + val divided = command.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + if (divided.isEmpty()) { + return NfcExecutionResult(false, rh.gs(R.string.wrong_format)) + } + return when (divided[0].uppercase(Locale.ROOT)) { + "LOOP" -> requireRemoteCommands { processLoop(divided) } + "AAPSCLIENT" -> requireRemoteCommands { if (divided.size == 2) processAapsClient(divided) else invalidFormat() } + "PUMP" -> requireRemoteCommands { processPump(divided) } + "PROFILE" -> requireRemoteCommands { processProfile(divided) } + "BASAL" -> requireRemoteCommands { processBasal(divided) } + "EXTENDED" -> requireRemoteCommands { processExtended(divided) } + "BOLUS" -> requireRemoteCommands { processBolus(divided) } + "CARBS" -> requireRemoteCommands { processCarbs(divided) } + "TARGET" -> requireRemoteCommands { processTarget(divided) } + "RESTART" -> + requireRemoteCommands { + if (divided.size == 1) processRestart() else invalidFormat() + } + else -> NfcExecutionResult(false, rh.gs(R.string.nfccommands_unknown_command)) + } + } + + private fun requireRemoteCommands(block: () -> NfcExecutionResult): NfcExecutionResult { + val remoteAllowed = preferences.get(BooleanKey.NfcAllowRemoteCommands) + if (!remoteAllowed) { + return NfcExecutionResult(false, rh.gs(R.string.nfccommands_remote_command_not_allowed)) + } + return block() + } + + private fun invalidFormat(): NfcExecutionResult = NfcExecutionResult(false, rh.gs(R.string.wrong_format)) + + private fun processLoop(divided: List): NfcExecutionResult { + if (divided.size !in 2..3) return invalidFormat() + val profile = runBlocking { profileFunction.getProfile() } ?: return NfcExecutionResult(false, rh.gs(app.aaps.core.ui.R.string.noprofile)) + return when (divided[1].uppercase(Locale.ROOT)) { + "DISABLE", "STOP" -> { + if (!loop.allowedNextModes().contains(RM.Mode.DISABLED_LOOP)) { + NfcExecutionResult(false, rh.gs(app.aaps.core.ui.R.string.loopisdisabled)) + } else { + val result = + loop.handleRunningModeChange( + newRM = RM.Mode.DISABLED_LOOP, + durationInMinutes = Int.MAX_VALUE, + action = Action.LOOP_DISABLED, + source = Sources.NfcCommands, + profile = profile, + ) + val messageId = + if (result) { + R.string.nfccommands_loop_has_been_disabled + } else { + R.string.nfccommands_remote_command_not_possible + } + NfcExecutionResult(result, rh.gs(messageId)) + } + } + "RESUME" -> { + if (!loop.allowedNextModes().contains(RM.Mode.RESUME) && loop.runningMode != RM.Mode.DISABLED_LOOP) { + NfcExecutionResult(false, rh.gs(R.string.nfccommands_remote_command_not_possible)) + } else { + val result = + loop.handleRunningModeChange( + newRM = RM.Mode.RESUME, + action = Action.RESUME, + source = Sources.NfcCommands, + profile = profile, + ) + val messageId = if (result) R.string.nfccommands_loop_resumed else R.string.nfccommands_remote_command_not_possible + NfcExecutionResult(result, rh.gs(messageId)) + } + } + "SUSPEND" -> { + val duration = SafeParse.stringToInt(divided.getOrNull(2)) + val normalizedDuration = duration.coerceIn(0, 180) + if (normalizedDuration == 0) { + NfcExecutionResult(false, rh.gs(R.string.nfccommands_wrong_duration)) + } else if (!loop.allowedNextModes().contains(RM.Mode.SUSPENDED_BY_USER)) { + NfcExecutionResult(false, rh.gs(R.string.nfccommands_remote_command_not_possible)) + } else { + val result = + loop.handleRunningModeChange( + newRM = RM.Mode.SUSPENDED_BY_USER, + durationInMinutes = normalizedDuration, + action = Action.SUSPEND, + source = Sources.NfcCommands, + profile = profile, + ) + val messageId = if (result) R.string.nfccommands_loop_suspended else R.string.nfccommands_remote_command_not_possible + NfcExecutionResult(result, rh.gs(messageId)) + } + } + "LGS" -> { + if (!loop.allowedNextModes().contains(RM.Mode.CLOSED_LOOP_LGS)) { + NfcExecutionResult(false, rh.gs(R.string.nfccommands_remote_command_not_possible)) + } else { + val result = + loop.handleRunningModeChange( + newRM = RM.Mode.CLOSED_LOOP_LGS, + action = Action.LGS_LOOP_MODE, + source = Sources.NfcCommands, + profile = profile, + ) + val message = + if (result) { + rh.gs(R.string.nfccommands_current_loop_mode, rh.gs(app.aaps.core.ui.R.string.lowglucosesuspend)) + } else { + rh.gs(R.string.nfccommands_remote_command_not_possible) + } + NfcExecutionResult(result, message) + } + } + "CLOSED" -> { + if (!loop.allowedNextModes().contains(RM.Mode.CLOSED_LOOP)) { + NfcExecutionResult(false, rh.gs(R.string.nfccommands_remote_command_not_possible)) + } else { + val result = + loop.handleRunningModeChange( + newRM = RM.Mode.CLOSED_LOOP, + action = Action.CLOSED_LOOP_MODE, + source = Sources.NfcCommands, + profile = profile, + ) + val message = + if (result) { + rh.gs(R.string.nfccommands_current_loop_mode, rh.gs(app.aaps.core.ui.R.string.closedloop)) + } else { + rh.gs(R.string.nfccommands_remote_command_not_possible) + } + NfcExecutionResult(result, message) + } + } + else -> invalidFormat() + } + } + + private fun processAapsClient(divided: List): NfcExecutionResult = + if (divided[1].equals("RESTART", ignoreCase = true)) { + rxBus.send(EventNSClientRestart()) + NfcExecutionResult(true, rh.gs(R.string.nfccommands_aapsclient_restart_sent)) + } else { + invalidFormat() + } + + private fun processPump(divided: List): NfcExecutionResult { + return when { + divided.size == 2 && divided[1].equals("CONNECT", ignoreCase = true) -> { + val profile = + runBlocking { profileFunction.getProfile() } ?: return NfcExecutionResult(false, rh.gs(app.aaps.core.ui.R.string.noprofile)) + if (!loop.allowedNextModes().contains(RM.Mode.RESUME)) { + NfcExecutionResult(true, rh.gs(app.aaps.core.interfaces.R.string.connected)) + } else { + val result = + loop.handleRunningModeChange( + newRM = RM.Mode.RESUME, + action = Action.RECONNECT, + source = Sources.NfcCommands, + profile = profile, + ) + val messageId = if (result) R.string.nfccommands_reconnect else R.string.nfccommands_remote_command_not_possible + NfcExecutionResult(result, rh.gs(messageId)) + } + } + divided.size == 3 && divided[1].equals("DISCONNECT", ignoreCase = true) -> { + val duration = SafeParse.stringToInt(divided[2]).coerceIn(0, 180) + val profile = + runBlocking { profileFunction.getProfile() } ?: return NfcExecutionResult(false, rh.gs(app.aaps.core.ui.R.string.noprofile)) + if (duration == 0) { + NfcExecutionResult(false, rh.gs(R.string.nfccommands_wrong_duration)) + } else { + val result = + loop.handleRunningModeChange( + durationInMinutes = duration, + profile = profile, + newRM = RM.Mode.DISCONNECTED_PUMP, + action = Action.DISCONNECT, + source = Sources.NfcCommands, + ) + val messageId = if (result) R.string.nfccommands_pump_disconnected else R.string.nfccommands_remote_command_not_possible + NfcExecutionResult(result, rh.gs(messageId)) + } + } + else -> invalidFormat() + } + } + + private fun processProfile(divided: List): NfcExecutionResult { + if (divided.size !in 2..3) return invalidFormat() + val indexToken = divided[1] + if (indexToken.any { !it.isDigit() }) return invalidFormat() + val index = SafeParse.stringToInt(indexToken) + val percentage = divided.getOrNull(2)?.let { SafeParse.stringToInt(it) } ?: 100 + val profileStore = localProfileManager.profile ?: return NfcExecutionResult(false, rh.gs(app.aaps.core.ui.R.string.notconfigured)) + val list = profileStore.getProfileList() + if (index <= 0 || percentage !in 10..500 || index > list.size) return invalidFormat() + val name = list[index - 1] as String + val created = + runBlocking { + profileFunction.createProfileSwitch( + profileStore = profileStore, + profileName = name, + durationInMinutes = 0, + percentage = percentage, + timeShiftInHours = 0, + timestamp = dateUtil.now(), + action = Action.PROFILE_SWITCH, + source = Sources.NfcCommands, + note = rh.gs(R.string.nfccommands_profile_switch_created), + listValues = listOf(ValueWithUnit.SimpleString(rh.gsNotLocalised(R.string.nfccommands_profile_switch_created))), + iCfg = insulin.iCfg, + ) + } + return if (created != null) { + NfcExecutionResult(true, rh.gs(R.string.nfccommands_profile_switch_created)) + } else { + NfcExecutionResult(false, rh.gs(app.aaps.core.ui.R.string.invalid_profile)) + } + } + + private fun processBasal(divided: List): NfcExecutionResult { + if (divided.size !in 2..3) return invalidFormat() + return when { + divided[1].equals("STOP", ignoreCase = true) || divided[1].equals("CANCEL", ignoreCase = true) -> { + commandQueue.cancelTempBasal( + enforceNew = true, + callback = + object : Callback() { + override fun run() { + if (!result.success) aapsLogger.error(LTag.NFC, "cancelTempBasal failed: ${result.comment}") + } + }, + ) + NfcExecutionResult(true, rh.gs(R.string.nfccommands_tempbasal_canceled)) + } + divided[1].endsWith("%") -> { + val profile = + runBlocking { profileFunction.getProfile() } ?: return NfcExecutionResult(false, rh.gs(app.aaps.core.ui.R.string.noprofile)) + var tempBasalPct = SafeParse.stringToInt(Strings.CS.removeEnd(divided[1], "%")) + val durationStep = + activePlugin.activePump + .model() + .tbrSettings() + ?.durationStep ?: 60 + val rawDuration = divided.getOrNull(2)?.let { SafeParse.stringToInt(it) } ?: durationStep + if (tempBasalPct == 0 && divided[1] != "0%") return invalidFormat() + if (rawDuration <= 0) { + return NfcExecutionResult(false, rh.gs(R.string.nfccommands_wrong_tbr_duration, durationStep)) + } + val duration = roundUpToStep(rawDuration, durationStep) + tempBasalPct = + constraintChecker.applyBasalPercentConstraints(ConstraintObject(tempBasalPct, aapsLogger), profile).value() + commandQueue.tempBasalPercent( + tempBasalPct, + duration, + true, + profile, + PumpSync.TemporaryBasalType.NORMAL, + object : Callback() { + override fun run() { + if (!result.success) aapsLogger.error(LTag.NFC, "tempBasalPercent failed: ${result.comment}") + } + }, + ) + NfcExecutionResult(true, rh.gs(R.string.nfccommands_command_executed, "BASAL ${divided.drop(1).joinToString(" ")}")) + } + else -> { + val profile = + runBlocking { profileFunction.getProfile() } ?: return NfcExecutionResult(false, rh.gs(app.aaps.core.ui.R.string.noprofile)) + var tempBasal = SafeParse.stringToDouble(divided[1]) + val durationStep = + activePlugin.activePump + .model() + .tbrSettings() + ?.durationStep ?: 60 + val rawDuration = divided.getOrNull(2)?.let { SafeParse.stringToInt(it) } ?: durationStep + if (tempBasal == 0.0 && divided[1] != "0") return invalidFormat() + if (rawDuration <= 0) { + return NfcExecutionResult(false, rh.gs(R.string.nfccommands_wrong_tbr_duration, durationStep)) + } + val duration = roundUpToStep(rawDuration, durationStep) + tempBasal = constraintChecker.applyBasalConstraints(ConstraintObject(tempBasal, aapsLogger), profile).value() + commandQueue.tempBasalAbsolute( + tempBasal, + duration, + true, + profile, + PumpSync.TemporaryBasalType.NORMAL, + object : Callback() { + override fun run() { + if (!result.success) aapsLogger.error(LTag.NFC, "tempBasalAbsolute failed: ${result.comment}") + } + }, + ) + NfcExecutionResult(true, rh.gs(R.string.nfccommands_command_executed, "BASAL ${divided.drop(1).joinToString(" ")}")) + } + } + } + + private fun processExtended(divided: List): NfcExecutionResult { + if (divided.size !in 2..3) return invalidFormat() + return when { + divided[1].equals("STOP", ignoreCase = true) || divided[1].equals("CANCEL", ignoreCase = true) -> { + commandQueue.cancelExtended( + object : Callback() { + override fun run() { + if (!result.success) aapsLogger.error(LTag.NFC, "cancelExtended failed: ${result.comment}") + } + }, + ) + NfcExecutionResult(true, rh.gs(R.string.nfccommands_extended_canceled)) + } + divided.size != 3 -> invalidFormat() + else -> { + var extended = SafeParse.stringToDouble(divided[1]) + val duration = SafeParse.stringToInt(divided[2]) + extended = constraintChecker.applyExtendedBolusConstraints(ConstraintObject(extended, aapsLogger)).value() + if (extended <= 0.0 || duration <= 0) return invalidFormat() + commandQueue.extendedBolus( + extended, + duration, + object : Callback() { + override fun run() { + if (!result.success) aapsLogger.error(LTag.NFC, "extendedBolus failed: ${result.comment}") + } + }, + ) + NfcExecutionResult(true, rh.gs(R.string.nfccommands_extended_set, extended, duration)) + } + } + } + + private fun processBolus(divided: List): NfcExecutionResult { + if (divided.size !in 2..3) return invalidFormat() + if (commandQueue.bolusInQueue()) { + return NfcExecutionResult(false, rh.gs(R.string.nfccommands_another_bolus_in_queue)) + } + if (dateUtil.now() - lastRemoteBolusTime < Constants.remoteBolusMinDistance) { + return NfcExecutionResult(false, rh.gs(R.string.nfccommands_remote_bolus_not_allowed)) + } + if (loop.runningMode.isSuspended()) { + return NfcExecutionResult(false, rh.gs(app.aaps.core.ui.R.string.pumpsuspended)) + } + var bolus = SafeParse.stringToDouble(divided[1]) + val isMeal = divided.size > 2 && divided[2].equals("MEAL", ignoreCase = true) + bolus = constraintChecker.applyBolusConstraints(ConstraintObject(bolus, aapsLogger)).value() + if (divided.size == 3 && !isMeal) return invalidFormat() + if (bolus <= 0.0) return invalidFormat() + val detailedBolusInfo = DetailedBolusInfo().apply { insulin = bolus } + commandQueue.bolus( + detailedBolusInfo, + object : Callback() { + override fun run() { + if (!result.success) { + aapsLogger.error(LTag.NFC, "bolus failed: ${result.comment}") + return + } + if (result.success) { + lastRemoteBolusTime = dateUtil.now() + if (isMeal) { + runBlocking { profileFunction.getProfile() }?.let { currentProfile -> + val eatingSoonTTDuration = preferences.get(IntKey.OverviewEatingSoonDuration) + val eatingSoonTT = preferences.get(UnitDoubleKey.OverviewEatingSoonTarget) + runBlocking { + persistenceLayer.insertAndCancelCurrentTemporaryTarget( + temporaryTarget = + TT( + timestamp = dateUtil.now(), + duration = TimeUnit.MINUTES.toMillis(eatingSoonTTDuration.toLong()), + reason = TT.Reason.EATING_SOON, + lowTarget = profileUtil.convertToMgdl(eatingSoonTT, profileUtil.units), + highTarget = profileUtil.convertToMgdl(eatingSoonTT, profileUtil.units), + ), + action = Action.TT, + source = Sources.NfcCommands, + note = null, + listValues = + listOf( + ValueWithUnit.TETTReason(TT.Reason.EATING_SOON), + ValueWithUnit.Mgdl(profileUtil.convertToMgdl(eatingSoonTT, profileUtil.units)), + ValueWithUnit.Minute( + TimeUnit.MILLISECONDS + .toMinutes( + TimeUnit.MINUTES.toMillis(eatingSoonTTDuration.toLong()), + ).toInt(), + ), + ), + ) + } + val tt = if (currentProfile.units == GlucoseUnit.MMOL) { + decimalFormatter.to1Decimal(eatingSoonTT) + } else { + decimalFormatter.to0Decimal(eatingSoonTT) + } + aapsLogger.debug(LTag.NFC, "Meal bolus temp target applied: $tt for $eatingSoonTTDuration min") + } + } + } + } + }, + ) + return NfcExecutionResult(true, rh.gs(R.string.nfccommands_command_executed, "BOLUS ${divided.drop(1).joinToString(" ")}")) + } + + private fun processCarbs(divided: List): NfcExecutionResult { + if (divided.size != 2) return invalidFormat() + var grams = SafeParse.stringToInt(divided[1]) + grams = constraintChecker.applyCarbsConstraints(ConstraintObject(grams, aapsLogger)).value() + if (grams == 0) return invalidFormat() + val detailedBolusInfo = + DetailedBolusInfo().apply { + carbs = grams.toDouble() + timestamp = dateUtil.now() + } + commandQueue.bolus( + detailedBolusInfo, + object : Callback() { + override fun run() { + if (!result.success) aapsLogger.error(LTag.NFC, "carbs bolus failed: ${result.comment}") + } + }, + ) + return NfcExecutionResult(true, rh.gs(R.string.nfccommands_carbs_set, grams)) + } + + private fun processTarget(divided: List): NfcExecutionResult { + if (divided.size != 2) return invalidFormat() + val isMeal = divided[1].equals("MEAL", ignoreCase = true) + val isActivity = divided[1].equals("ACTIVITY", ignoreCase = true) + val isHypo = divided[1].equals("HYPO", ignoreCase = true) + val isStop = divided[1].equals("STOP", ignoreCase = true) || divided[1].equals("CANCEL", ignoreCase = true) + return when { + isMeal || isActivity || isHypo -> { + val units = profileUtil.units + var reason = TT.Reason.EATING_SOON + var ttDuration = 0 + var tt = 0.0 + when { + isMeal -> { + ttDuration = preferences.get(IntKey.OverviewEatingSoonDuration) + tt = preferences.get(UnitDoubleKey.OverviewEatingSoonTarget) + reason = TT.Reason.EATING_SOON + } + isActivity -> { + ttDuration = preferences.get(IntKey.OverviewActivityDuration) + tt = preferences.get(UnitDoubleKey.OverviewActivityTarget) + reason = TT.Reason.ACTIVITY + } + isHypo -> { + ttDuration = preferences.get(IntKey.OverviewHypoDuration) + tt = preferences.get(UnitDoubleKey.OverviewHypoTarget) + reason = TT.Reason.HYPOGLYCEMIA + } + } + runBlocking { + persistenceLayer.insertAndCancelCurrentTemporaryTarget( + temporaryTarget = + TT( + timestamp = dateUtil.now(), + duration = TimeUnit.MINUTES.toMillis(ttDuration.toLong()), + reason = reason, + lowTarget = profileUtil.convertToMgdl(tt, profileUtil.units), + highTarget = profileUtil.convertToMgdl(tt, profileUtil.units), + ), + action = Action.TT, + source = Sources.NfcCommands, + note = null, + listValues = + listOf( + ValueWithUnit.fromGlucoseUnit(tt, units), + ValueWithUnit.Minute(ttDuration), + ), + ) + } + val ttString = if (units == GlucoseUnit.MMOL) decimalFormatter.to1Decimal(tt) else decimalFormatter.to0Decimal(tt) + NfcExecutionResult(true, rh.gs(R.string.nfccommands_tt_set, ttString, ttDuration)) + } + isStop -> { + runBlocking { + persistenceLayer.cancelCurrentTemporaryTargetIfAny( + timestamp = dateUtil.now(), + action = Action.CANCEL_TT, + source = Sources.NfcCommands, + note = rh.gs(R.string.nfccommands_tt_canceled), + listValues = listOf(ValueWithUnit.SimpleString(rh.gsNotLocalised(R.string.nfccommands_tt_canceled))), + ) + } + NfcExecutionResult(true, rh.gs(R.string.nfccommands_tt_canceled)) + } + else -> invalidFormat() + } + } + + private fun processRestart(): NfcExecutionResult { + configBuilder.exitApp("NFC", Sources.NfcCommands, true) + return NfcExecutionResult(true, rh.gs(R.string.nfccommands_restarting)) + } + + // NFC tags are written ahead of time and may be scanned on any supported pump. + // Rather than rejecting a duration that is not an exact multiple of the pump's + // step size, round it UP to the next valid multiple so the command always runs. + private fun roundUpToStep( + value: Int, + step: Int, + ): Int = if (value % step == 0) value else ((value / step) + 1) * step + } diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcControlActivity.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcControlActivity.kt new file mode 100644 index 000000000000..9ac387571526 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcControlActivity.kt @@ -0,0 +1,192 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.app.Activity +import android.content.Intent +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.Ndef +import android.os.Bundle +import android.widget.Toast +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.plugins.main.R +import dagger.android.AndroidInjection +import java.nio.charset.StandardCharsets +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import javax.inject.Inject + +open class NfcControlActivity : Activity() { + @Inject lateinit var nfcPlugin: NfcCommandsPlugin + + @Inject lateinit var aapsLogger: AAPSLogger + + @Inject lateinit var rh: ResourceHelper + + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private var currentTask: Future<*>? = null + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidInjection.inject(this) + super.onCreate(savedInstanceState) + } + + override fun onResume() { + super.onResume() + currentTask = executor.submit { + handleIntent(intent) + runOnUiThread { finish() } + } + } + + override fun onDestroy() { + currentTask?.cancel(true) + executor.shutdown() + super.onDestroy() + } + + fun handleIntent(intent: Intent?) { + if (!nfcPlugin.isEnabled()) { + aapsLogger.debug(LTag.NFC, "NFC Plugin is disabled. Ignoring tag.") + showToast(rh.gs(R.string.nfccommands_plugin_disabled)) + return + } + + if (intent == null || NfcAdapter.ACTION_NDEF_DISCOVERED != intent.action) return + + // Require a physical Tag object. Only the Android NFC subsystem can supply this; + // an explicit intent crafted by another app cannot forge a real Tag instance, + // so this check enforces that an actual NFC scan took place. + @Suppress("DEPRECATION") + val nfcTag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + if (nfcTag == null) { + aapsLogger.debug(LTag.NFC, "Rejected intent without physical NFC tag") + return + } + + @Suppress("DEPRECATION") + val rawMsgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) ?: return + if (rawMsgs.isEmpty()) return + val message = rawMsgs[0] as? NdefMessage ?: return + val record = message.records?.firstOrNull() ?: return + + // Validate the record type in-app. The manifest enforces the MIME type + // for implicit NFC dispatch, but not for explicit intents to this exported activity. + if (record.tnf != NdefRecord.TNF_MIME_MEDIA || + String(record.type, StandardCharsets.US_ASCII) != NfcTokenSupport.MIME_TYPE + ) { + aapsLogger.debug(LTag.NFC, "Rejected NFC record with unexpected TNF/type") + return + } + + val payload = record.payload ?: return + val token = String(payload, StandardCharsets.UTF_8) + aapsLogger.debug(LTag.NFC, "NFC token scanned") + + val tagUid = NfcTokenSupport.tagUidHex(nfcTag.id) + when (val prep = nfcPlugin.prepareExecution(token, tagUid)) { + is NfcPrepareResult.Error -> { + if (prep.eraseTag) erasePhysicalTag(intent) + showToast(prep.message) + return + } + is NfcPrepareResult.Ready -> { + if (prep.rewriteWith != null) { + val newCreatedTag = + NfcCreatedTag( + id = prep.rewriteWith.tokenId, + name = prep.oldTag!!.name, + commands = prep.oldTag.commands, + token = prep.rewriteWith.token, + createdAtMillis = prep.rewriteWith.issuedAtMillis, + expiresAtMillis = prep.rewriteWith.expiresAtMillis, + ) + val ndefMessage = + NdefMessage( + arrayOf( + NdefRecord.createMime( + NfcTokenSupport.MIME_TYPE, + prep.rewriteWith.token.toByteArray(), + ), + ), + ) + val writeSuccess = writeNdefToPhysicalTag(nfcTag, ndefMessage) + + if (!writeSuccess) { + showToast(rh.gs(R.string.nfccommands_tag_rewrite_failed)) + return + } + nfcPlugin.replaceTag(prep.oldTag.id, newCreatedTag) + } + val result = nfcPlugin.executeCascade(prep.commands) + appendReadLogEntry(prep.tokenId, result) + showToast(result.message) + } + } + } + + /** Overridable in tests to avoid SharedPreferences access. */ + open fun appendReadLogEntry(tokenId: String, result: NfcExecutionResult) { + val tagName = NfcTokenSupport.loadCreatedTags(this).find { it.id == tokenId }?.name ?: tokenId + NfcTokenSupport.appendLogEntry( + this, + NfcLogEntry( + timestamp = System.currentTimeMillis(), + tagName = tagName, + action = "READ", + success = result.success, + message = result.message, + ), + ) + } + + /** Overridable in tests to avoid real NFC I/O. */ + open fun writeNdefToPhysicalTag( + nfcTag: Tag, + message: NdefMessage, + ): Boolean = + try { + val ndef = Ndef.get(nfcTag) ?: return false + ndef.connect() + try { + ndef.writeNdefMessage(message) + true + } finally { + ndef.close() + } + } catch (e: Exception) { + aapsLogger.error(LTag.NFC, "Failed to rewrite expiring tag", e) + false + } + + /** Overridable in tests to avoid real NFC I/O. */ + open fun erasePhysicalTag(intent: Intent) { + @Suppress("DEPRECATION") + val nfcTag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: return + try { + val ndef = Ndef.get(nfcTag) ?: return + ndef.connect() + try { + ndef.writeNdefMessage( + NdefMessage(arrayOf(NdefRecord(NdefRecord.TNF_EMPTY, null, null, null))), + ) + } finally { + ndef.close() + } + } catch (e: Exception) { + aapsLogger.error(LTag.NFC, "Failed to erase blacklisted tag", e) + } + } + + private fun showToast(message: String) { + runCatching { + runOnUiThread { + Toast.makeText(this, message, Toast.LENGTH_LONG)?.show() + } + } + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcLogAdapter.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcLogAdapter.kt new file mode 100644 index 000000000000..3890f8a329b4 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcLogAdapter.kt @@ -0,0 +1,56 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import app.aaps.plugins.main.R +import app.aaps.plugins.main.databinding.NfccommandsLogItemBinding +import java.text.DateFormat + +class NfcLogAdapter( + private val entries: MutableList, +) : RecyclerView.Adapter() { + + inner class ViewHolder( + val binding: NfccommandsLogItemBinding, + ) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val binding = NfccommandsLogItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + val entry = entries[position] + val ctx = holder.itemView.context + + val actionLabel = + if (entry.action == "WRITE") { + ctx.getString(R.string.nfccommands_log_action_write) + } else { + ctx.getString(R.string.nfccommands_log_action_read) + } + holder.binding.logActionChip.text = actionLabel + holder.binding.logTagName.text = entry.tagName + holder.binding.logTimestamp.text = formatter.format(entry.timestamp) + holder.binding.logMessage.text = entry.message + } + + override fun getItemCount() = entries.size + + fun updateEntries(newEntries: List) { + entries.clear() + entries.addAll(newEntries) + notifyDataSetChanged() + } + + companion object { + private val formatter = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcLogFragment.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcLogFragment.kt new file mode 100644 index 000000000000..72cfea7320ec --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcLogFragment.kt @@ -0,0 +1,49 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import app.aaps.plugins.main.databinding.NfccommandsLogFragmentBinding + +class NfcLogFragment : Fragment() { + private var bindingOrNull: NfccommandsLogFragmentBinding? = null + private val binding get() = bindingOrNull!! + + private val logEntries = mutableListOf() + private lateinit var adapter: NfcLogAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + bindingOrNull = NfccommandsLogFragmentBinding.inflate(inflater, container, false) + + adapter = NfcLogAdapter(logEntries) + binding.logRecycler.layoutManager = LinearLayoutManager(requireContext()) + binding.logRecycler.adapter = adapter + + return binding.root + } + + override fun onResume() { + super.onResume() + refresh() + } + + fun refresh() { + val binding = bindingOrNull ?: return + val entries = NfcTokenSupport.loadLog(requireContext()) + adapter.updateEntries(entries) + binding.logEmptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE + binding.logRecycler.visibility = if (entries.isEmpty()) View.GONE else View.VISIBLE + } + + override fun onDestroyView() { + super.onDestroyView() + bindingOrNull = null + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcPagerAdapter.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcPagerAdapter.kt new file mode 100644 index 000000000000..eeb6bb223d98 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcPagerAdapter.kt @@ -0,0 +1,19 @@ +package app.aaps.plugins.main.general.nfcCommands + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter + +class NfcPagerAdapter( + fragment: Fragment, +) : FragmentStateAdapter(fragment) { + val buildFragment = NfcBuildFragment() + val tagsFragment = NfcTagsFragment() + + override fun getItemCount() = 2 + + override fun createFragment(position: Int): Fragment = + when (position) { + 0 -> buildFragment + else -> tagsFragment + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTagsAdapter.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTagsAdapter.kt new file mode 100644 index 000000000000..581ad83d7a95 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTagsAdapter.kt @@ -0,0 +1,79 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import app.aaps.plugins.main.R +import app.aaps.plugins.main.databinding.NfccommandsTagItemBinding +import java.text.DateFormat + +class NfcTagsAdapter( + private val tags: MutableList, + private val onDelete: (NfcCreatedTag) -> Unit, +) : RecyclerView.Adapter() { + inner class ViewHolder( + val binding: NfccommandsTagItemBinding, + ) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val binding = NfccommandsTagItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + val tag = tags[position] + val ctx = holder.itemView.context + val now = System.currentTimeMillis() + + holder.binding.tagName.text = tag.name + holder.binding.tagCommands.text = + tag.commands + .mapIndexed { i, cmd -> + ctx.getString(R.string.nfccommands_cascade_step_label, i + 1, cmd) + }.joinToString("\n") + + val dayMillis = 24L * 60L * 60L * 1000L + when { + tag.isExpired(now) -> { + holder.binding.tagExpiry.text = ctx.getString(R.string.nfccommands_tag_expired_at, formatter.format(tag.expiresAtMillis)) + holder.binding.tagExpiry.chipBackgroundColor = + ColorStateList.valueOf(ContextCompat.getColor(ctx, R.color.nfccommands_tag_expired)) + } + tag.isExpiringSoon(now) -> { + val daysLeft = ((tag.expiresAtMillis - now) / dayMillis).toInt().coerceAtLeast(0) + holder.binding.tagExpiry.text = ctx.getString(R.string.nfccommands_tag_expires_soon, daysLeft) + holder.binding.tagExpiry.chipBackgroundColor = + ColorStateList.valueOf(ContextCompat.getColor(ctx, R.color.nfccommands_tag_expiring_soon)) + } + else -> { + holder.binding.tagExpiry.text = ctx.getString(R.string.nfccommands_tag_expires_at, formatter.format(tag.expiresAtMillis)) + holder.binding.tagExpiry.chipBackgroundColor = + ColorStateList.valueOf(ContextCompat.getColor(ctx, R.color.nfccommands_tag_valid)) + } + } + + holder.binding.tagDeleteButton.setOnClickListener { + onDelete(tag) + } + } + + override fun getItemCount() = tags.size + + companion object { + private val formatter = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + } + + fun updateTags(newTags: List) { + tags.clear() + tags.addAll(newTags) + notifyDataSetChanged() + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTagsFragment.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTagsFragment.kt new file mode 100644 index 000000000000..3e84aaa80332 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTagsFragment.kt @@ -0,0 +1,59 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import app.aaps.plugins.main.R +import app.aaps.plugins.main.databinding.NfccommandsTagsFragmentBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class NfcTagsFragment : Fragment() { + private var _binding: NfccommandsTagsFragmentBinding? = null + private val binding get() = _binding!! + + private val tagsList = mutableListOf() + private lateinit var adapter: NfcTagsAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = NfccommandsTagsFragmentBinding.inflate(inflater, container, false) + + adapter = NfcTagsAdapter(tagsList) { tag -> confirmDelete(tag) } + binding.tagsRecycler.layoutManager = LinearLayoutManager(requireContext()) + binding.tagsRecycler.adapter = adapter + + refresh() + + return binding.root + } + + fun refresh() { + val binding = _binding ?: return + val tags = NfcTokenSupport.loadCreatedTags(requireContext()) + adapter.updateTags(tags) + binding.emptyState.visibility = if (tags.isEmpty()) View.VISIBLE else View.GONE + binding.tagsRecycler.visibility = if (tags.isEmpty()) View.GONE else View.VISIBLE + } + + private fun confirmDelete(tag: NfcCreatedTag) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.nfccommands_delete_confirm_title) + .setMessage(R.string.nfccommands_delete_confirm_msg) + .setPositiveButton(R.string.nfccommands_delete_confirm_ok) { _, _ -> + NfcTokenSupport.blacklistTag(requireContext(), tag) + refresh() + }.setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTokenSupport.kt b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTokenSupport.kt new file mode 100644 index 000000000000..61f0688323f4 --- /dev/null +++ b/plugins/main/src/main/kotlin/app/aaps/plugins/main/general/nfcCommands/NfcTokenSupport.kt @@ -0,0 +1,542 @@ +package app.aaps.plugins.main.general.nfcCommands + +import android.content.Context +import android.content.SharedPreferences +import androidx.annotation.StringRes +import androidx.preference.PreferenceManager +import app.aaps.plugins.main.R +import org.json.JSONArray +import org.json.JSONObject +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import java.util.UUID +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +data class NfcCommandTemplate( + @StringRes val labelResId: Int, + val commandPrefix: String, + val fixedCommand: String? = null, + @StringRes val argumentsHintResId: Int = 0, + val requiresArguments: Boolean = false, +) + +data class NfcCreatedTag( + val id: String, + val name: String, + val commands: List, + val token: String, + val createdAtMillis: Long, + val expiresAtMillis: Long, +) { + fun isExpired(nowMillis: Long): Boolean = nowMillis >= expiresAtMillis + + fun isExpiringSoon(nowMillis: Long): Boolean = + !isExpired(nowMillis) && (expiresAtMillis - nowMillis) <= NfcTokenSupport.THIRTY_DAYS_MILLIS +} + +data class NfcBlacklistEntry( + val tokenId: String, + val expiresAtMillis: Long, +) + +data class NfcLogEntry( + val timestamp: Long, + val tagName: String, + val action: String, + val success: Boolean, + val message: String, +) + +data class NfcIssuedToken( + val token: String, + val tokenId: String, + val issuedAtMillis: Long, + val expiresAtMillis: Long, +) + +sealed class NfcTokenVerificationResult { + data class Success( + val commands: List, + val tokenId: String, + val issuedAtMillis: Long, + val expiresAtMillis: Long, + ) : NfcTokenVerificationResult() + + data class Failure( + val reason: String, + ) : NfcTokenVerificationResult() +} + +object NfcTokenSupport { + const val MIME_TYPE: String = "application/vnd.app.aaps.command" + const val ONE_YEAR_MILLIS: Long = 365L * 24L * 60L * 60L * 1000L + const val THIRTY_DAYS_MILLIS: Long = 30L * 24L * 60L * 60L * 1000L + + private const val PREFS_SECRET = "nfccommunicator_jwt_secret_v1" + private const val PREFS_TAGS = "nfccommunicator_created_tags_v1" + private const val PREFS_BLACKLIST = "nfccommunicator_blacklisted_tokens_v1" + private const val PREFS_LOG = "nfccommunicator_log_v1" + private const val LOG_MAX_ENTRIES = 100 + + private val commandTemplates = + listOf( + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_loop_stop, commandPrefix = "LOOP STOP"), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_loop_resume, commandPrefix = "LOOP RESUME"), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_loop_closed, commandPrefix = "LOOP CLOSED"), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_loop_lgs, commandPrefix = "LOOP LGS"), + NfcCommandTemplate( + labelResId = R.string.nfccommands_cmd_loop_suspend, + commandPrefix = "LOOP SUSPEND", + argumentsHintResId = R.string.nfccommands_hint_minutes, + requiresArguments = true, + ), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_aapsclient_restart, commandPrefix = "AAPSCLIENT RESTART"), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_pump_connect, commandPrefix = "PUMP CONNECT"), + NfcCommandTemplate( + labelResId = R.string.nfccommands_cmd_pump_disconnect, + commandPrefix = "PUMP DISCONNECT", + argumentsHintResId = R.string.nfccommands_hint_minutes, + requiresArguments = true, + ), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_basal_stop, commandPrefix = "BASAL STOP"), + NfcCommandTemplate( + labelResId = R.string.nfccommands_cmd_basal_absolute, + commandPrefix = "BASAL", + argumentsHintResId = R.string.nfccommands_hint_basal_abs, + requiresArguments = true, + ), + NfcCommandTemplate( + labelResId = R.string.nfccommands_cmd_basal_percent, + commandPrefix = "BASAL", + argumentsHintResId = R.string.nfccommands_hint_basal_pct, + requiresArguments = true, + ), + NfcCommandTemplate( + labelResId = R.string.nfccommands_cmd_bolus, + commandPrefix = "BOLUS", + argumentsHintResId = R.string.nfccommands_hint_bolus, + requiresArguments = true, + ), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_extended_stop, commandPrefix = "EXTENDED STOP"), + NfcCommandTemplate( + labelResId = R.string.nfccommands_cmd_extended_bolus, + commandPrefix = "EXTENDED", + argumentsHintResId = R.string.nfccommands_hint_extended, + requiresArguments = true, + ), + NfcCommandTemplate( + labelResId = R.string.nfccommands_cmd_profile_switch, + commandPrefix = "PROFILE", + argumentsHintResId = R.string.nfccommands_hint_profile, + requiresArguments = true, + ), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_target_meal, commandPrefix = "TARGET MEAL"), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_target_activity, commandPrefix = "TARGET ACTIVITY"), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_target_hypo, commandPrefix = "TARGET HYPO"), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_target_stop, commandPrefix = "TARGET STOP"), + NfcCommandTemplate( + labelResId = R.string.nfccommands_cmd_carbs, + commandPrefix = "CARBS", + argumentsHintResId = R.string.nfccommands_hint_grams, + requiresArguments = true, + ), + NfcCommandTemplate(labelResId = R.string.nfccommands_cmd_restart_aaps, commandPrefix = "RESTART"), + ) + + fun availableCommands(): List = commandTemplates + + fun buildCommand( + template: NfcCommandTemplate, + args: String, + ): String? { + template.fixedCommand?.let { return it } + val cleanArgs = args.trim() + if (template.requiresArguments && cleanArgs.isEmpty()) return null + return if (template.requiresArguments) "${template.commandPrefix} $cleanArgs" else template.commandPrefix + } + + fun buildCascade(steps: List>): List? { + if (steps.isEmpty()) return null + val result = mutableListOf() + for ((template, args) in steps) { + val cmd = buildCommand(template, args) ?: return null + result.add(cmd) + } + return result + } + + /** + * Encodes a raw NFC tag UID byte array as a lowercase hex string, or returns null if [id] is null. + * Used to produce and verify the `tid` claim in issued tokens. + */ + fun tagUidHex(id: ByteArray?): String? = id?.joinToString("") { "%02x".format(it) } + + fun issueToken( + context: Context, + command: String, + nowMillis: Long = System.currentTimeMillis(), + tagUid: String? = null, + ): NfcIssuedToken = issueToken(secretBytes(context), listOf(command), nowMillis, tagUid) + + fun issueToken( + context: Context, + commands: List, + nowMillis: Long = System.currentTimeMillis(), + tagUid: String? = null, + ): NfcIssuedToken = issueToken(secretBytes(context), commands, nowMillis, tagUid) + + internal fun issueToken( + secret: ByteArray, + command: String, + nowMillis: Long, + tagUid: String? = null, + ): NfcIssuedToken = issueToken(secret, listOf(command), nowMillis, tagUid) + + internal fun issueToken( + secret: ByteArray, + commands: List, + nowMillis: Long, + tagUid: String? = null, + ): NfcIssuedToken { + val tokenId = UUID.randomUUID().toString() + val expiresAtMillis = nowMillis + ONE_YEAR_MILLIS + val headerJson = + JSONObject() + .put("alg", "HS256") + .put("typ", "JWT") + val cmdsArray = JSONArray() + commands.forEach { cmdsArray.put(it) } + val payloadJson = + JSONObject() + .put("jti", tokenId) + .put("cmds", cmdsArray) + .put("iat", nowMillis / 1000L) + .put("exp", expiresAtMillis / 1000L) + if (tagUid != null) payloadJson.put("tid", tagUid) + val encodedHeader = encodeSegment(headerJson.toString().toByteArray(StandardCharsets.UTF_8)) + val encodedPayload = encodeSegment(payloadJson.toString().toByteArray(StandardCharsets.UTF_8)) + val signingInput = "$encodedHeader.$encodedPayload" + val signature = encodeSegment(sign(signingInput, secret)) + return NfcIssuedToken( + token = "$signingInput.$signature", + tokenId = tokenId, + issuedAtMillis = nowMillis, + expiresAtMillis = expiresAtMillis, + ) + } + + fun verifyToken( + context: Context, + token: String, + nowMillis: Long = System.currentTimeMillis(), + tagUid: String? = null, + ): NfcTokenVerificationResult = verifyToken(secretBytes(context), token, nowMillis, tagUid) + + internal fun verifyToken( + secret: ByteArray, + token: String, + nowMillis: Long, + tagUid: String? = null, + ): NfcTokenVerificationResult { + val parts = token.split(".") + if (parts.size != 3) return NfcTokenVerificationResult.Failure("Malformed token") + + val signingInput = "${parts[0]}.${parts[1]}" + val expectedSignature = sign(signingInput, secret) + val actualSignature = decodeSegment(parts[2]) ?: return NfcTokenVerificationResult.Failure("Malformed signature") + if (!MessageDigest.isEqual(expectedSignature, actualSignature)) { + return NfcTokenVerificationResult.Failure("Invalid token signature") + } + + val payloadBytes = decodeSegment(parts[1]) ?: return NfcTokenVerificationResult.Failure("Malformed payload") + val payload = + runCatching { JSONObject(String(payloadBytes, StandardCharsets.UTF_8)) }.getOrNull() + ?: return NfcTokenVerificationResult.Failure("Malformed payload") + + val tokenId = payload.optString("jti") + val issuedAtMillis = payload.optLong("iat") * 1000L + val expiresAtMillis = payload.optLong("exp") * 1000L + + // Decode commands: try "cmds" (JSONArray) first, fall back to "cmd" (String) for legacy tokens + val cmdsArray = payload.optJSONArray("cmds") + val commands: List + if (cmdsArray != null && cmdsArray.length() > 0) { + commands = (0 until cmdsArray.length()).map { cmdsArray.optString(it) }.filter { it.isNotBlank() } + if (commands.isEmpty()) return NfcTokenVerificationResult.Failure("Missing token claims") + } else { + val cmd = payload.optString("cmd") + if (cmd.isBlank()) return NfcTokenVerificationResult.Failure("Missing token claims") + commands = listOf(cmd) + } + + if (tokenId.isBlank() || expiresAtMillis == 0L) { + return NfcTokenVerificationResult.Failure("Missing token claims") + } + if (issuedAtMillis == 0L || issuedAtMillis > expiresAtMillis) { + return NfcTokenVerificationResult.Failure("Invalid token timestamps") + } + if (nowMillis >= expiresAtMillis) { + return NfcTokenVerificationResult.Failure("Token expired") + } + + // UID binding: if the token carries a tid claim the physical tag UID must match. + // Tokens written without a tid claim (legacy) pass through unconditionally. + val claimedUid = payload.optString("tid").takeIf { it.isNotEmpty() } + if (claimedUid != null) { + if (tagUid == null || !claimedUid.equals(tagUid, ignoreCase = true)) { + return NfcTokenVerificationResult.Failure("Tag UID mismatch") + } + } + + return NfcTokenVerificationResult.Success( + commands = commands, + tokenId = tokenId, + issuedAtMillis = issuedAtMillis, + expiresAtMillis = expiresAtMillis, + ) + } + + fun loadCreatedTags(context: Context): List = loadCreatedTags(PreferenceManager.getDefaultSharedPreferences(context)) + + internal fun loadCreatedTags(prefs: SharedPreferences): List { + val raw = prefs.getString(PREFS_TAGS, "[]").orEmpty() + val tags = mutableListOf() + val array = runCatching { JSONArray(raw) }.getOrElse { JSONArray() } + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + // Check "commands" (JSONArray) first; fall back to legacy "command" (String) + val commandsJson = item.optJSONArray("commands") + val commands = + if (commandsJson != null && commandsJson.length() > 0) { + (0 until commandsJson.length()).map { commandsJson.optString(it) } + } else { + val cmd = item.optString("command") + if (cmd.isNotBlank()) listOf(cmd) else emptyList() + } + tags.add( + NfcCreatedTag( + id = item.optString("id"), + name = item.optString("name"), + commands = commands, + token = item.optString("token"), + createdAtMillis = item.optLong("createdAtMillis"), + expiresAtMillis = item.optLong("expiresAtMillis"), + ), + ) + } + return tags.sortedByDescending { it.createdAtMillis } + } + + fun saveCreatedTag( + context: Context, + tag: NfcCreatedTag, + ) { + saveCreatedTag(PreferenceManager.getDefaultSharedPreferences(context), tag) + } + + internal fun saveCreatedTag( + prefs: SharedPreferences, + tag: NfcCreatedTag, + ) { + val updated = loadCreatedTags(prefs).filterNot { it.id == tag.id }.toMutableList() + updated.add(0, tag) + saveCreatedTagList(prefs, updated) + } + + fun replaceTag( + context: Context, + oldId: String, + newTag: NfcCreatedTag, + ) { + replaceTag(PreferenceManager.getDefaultSharedPreferences(context), oldId, newTag) + } + + internal fun replaceTag( + prefs: SharedPreferences, + oldId: String, + newTag: NfcCreatedTag, + ) { + val updated = loadCreatedTags(prefs).filterNot { it.id == oldId }.toMutableList() + updated.add(0, newTag) + saveCreatedTagList(prefs, updated) + } + + private fun saveCreatedTagList( + prefs: SharedPreferences, + tags: List, + ) { + val array = JSONArray() + tags.forEach { current -> + val cmdsArray = JSONArray() + current.commands.forEach { cmdsArray.put(it) } + array.put( + JSONObject() + .put("id", current.id) + .put("name", current.name) + .put("commands", cmdsArray) + .put("token", current.token) + .put("createdAtMillis", current.createdAtMillis) + .put("expiresAtMillis", current.expiresAtMillis), + ) + } + prefs.edit().putString(PREFS_TAGS, array.toString()).apply() + } + + fun loadBlacklistedTokens( + context: Context, + nowMillis: Long = System.currentTimeMillis(), + ): List = loadBlacklistedTokens(PreferenceManager.getDefaultSharedPreferences(context), nowMillis) + + internal fun loadBlacklistedTokens( + prefs: SharedPreferences, + nowMillis: Long = System.currentTimeMillis(), + ): List { + val raw = prefs.getString(PREFS_BLACKLIST, "[]").orEmpty() + val array = runCatching { JSONArray(raw) }.getOrElse { JSONArray() } + val entries = mutableListOf() + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + val entry = + NfcBlacklistEntry( + tokenId = item.optString("tokenId"), + expiresAtMillis = item.optLong("expiresAtMillis"), + ) + if (entry.expiresAtMillis > nowMillis) entries.add(entry) + } + return entries + } + + fun blacklistTag( + context: Context, + tag: NfcCreatedTag, + ) { + blacklistTag(PreferenceManager.getDefaultSharedPreferences(context), tag) + } + + internal fun blacklistTag( + prefs: SharedPreferences, + tag: NfcCreatedTag, + ) { + val updatedTags = loadCreatedTags(prefs).filterNot { it.id == tag.id } + saveCreatedTagList(prefs, updatedTags) + + val existing = loadBlacklistedTokens(prefs).toMutableList() + existing.add(NfcBlacklistEntry(tag.id, tag.expiresAtMillis)) + val array = JSONArray() + existing.forEach { entry -> + array.put( + JSONObject() + .put("tokenId", entry.tokenId) + .put("expiresAtMillis", entry.expiresAtMillis), + ) + } + prefs.edit().putString(PREFS_BLACKLIST, array.toString()).apply() + } + + fun clearBlacklist(context: Context) { + clearBlacklist(PreferenceManager.getDefaultSharedPreferences(context)) + } + + internal fun clearBlacklist(prefs: SharedPreferences) { + prefs.edit().putString(PREFS_BLACKLIST, "[]").apply() + } + + fun isBlacklisted( + context: Context, + tokenId: String, + nowMillis: Long = System.currentTimeMillis(), + ): Boolean = isBlacklisted(PreferenceManager.getDefaultSharedPreferences(context), tokenId, nowMillis) + + internal fun isBlacklisted( + prefs: SharedPreferences, + tokenId: String, + nowMillis: Long = System.currentTimeMillis(), + ): Boolean { + val active = loadBlacklistedTokens(prefs, nowMillis) + val pruned = JSONArray() + active.forEach { entry -> + pruned.put( + JSONObject() + .put("tokenId", entry.tokenId) + .put("expiresAtMillis", entry.expiresAtMillis), + ) + } + prefs.edit().putString(PREFS_BLACKLIST, pruned.toString()).apply() + return active.any { it.tokenId == tokenId } + } + + fun appendLogEntry( + context: Context, + entry: NfcLogEntry, + ) { + appendLogEntry(PreferenceManager.getDefaultSharedPreferences(context), entry) + } + + internal fun appendLogEntry( + prefs: SharedPreferences, + entry: NfcLogEntry, + ) { + val existing = loadLog(prefs).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), + ) + } + prefs.edit().putString(PREFS_LOG, array.toString()).apply() + } + + fun loadLog(context: Context): List = loadLog(PreferenceManager.getDefaultSharedPreferences(context)) + + internal fun loadLog(prefs: SharedPreferences): List = + try { + val array = JSONArray(prefs.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() + } + + private fun secretBytes(context: Context): ByteArray { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val existing = prefs.getString(PREFS_SECRET, null) + if (!existing.isNullOrBlank()) { + decodeSegment(existing)?.let { return it } + } + val secret = ByteArray(32) + SecureRandom().nextBytes(secret) + prefs.edit().putString(PREFS_SECRET, encodeSegment(secret)).apply() + return secret + } + + private fun sign( + signingInput: String, + secret: ByteArray, + ): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(secret, "HmacSHA256")) + return mac.doFinal(signingInput.toByteArray(StandardCharsets.UTF_8)) + } + + private fun encodeSegment(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + + private fun decodeSegment(value: String): ByteArray? = runCatching { Base64.getUrlDecoder().decode(value) }.getOrNull() +} 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/layout/nfccommands_build_activity.xml b/plugins/main/src/main/res/layout/nfccommands_build_activity.xml new file mode 100644 index 000000000000..35ef090a2ae9 --- /dev/null +++ b/plugins/main/src/main/res/layout/nfccommands_build_activity.xml @@ -0,0 +1,6 @@ + + diff --git a/plugins/main/src/main/res/layout/nfccommands_build_fragment.xml b/plugins/main/src/main/res/layout/nfccommands_build_fragment.xml new file mode 100644 index 000000000000..1e5972b3a63e --- /dev/null +++ b/plugins/main/src/main/res/layout/nfccommands_build_fragment.xml @@ -0,0 +1,793 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +