From 6c0b819741167143d4b4e9637911a58696180318 Mon Sep 17 00:00:00 2001 From: Paul Colby Date: Thu, 20 Jun 2024 21:50:50 +1000 Subject: [PATCH 1/5] Use WRITE_SECURE_SETTINGS permission, if available Just a first working implementation. More to come. --- app/src/main/AndroidManifest.xml | 2 ++ .../colby/nfcquicksettings/NfcTileService.kt | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ef11d5..edf2ff7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + Date: Thu, 20 Jun 2024 21:57:25 +1000 Subject: [PATCH 2/5] Make the tile more responsive Will clean-up / refactor a little after some initial testing. --- .../java/au/id/colby/nfcquicksettings/NfcTileService.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt b/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt index 39b3afb..087b1b5 100644 --- a/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt +++ b/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt @@ -85,11 +85,20 @@ class NfcTileService : TileService() { Log.i(TAG, "Have WRITE_SECURE_SETTINGS permission") val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(this) adapter?.apply { + val wasEnabled = adapter.isEnabled val methodName = if (adapter.isEnabled) "disable" else "enable" try { Log.d(TAG, "Invoking NfcAdapter::$methodName()") val result = NfcAdapter::class.java.getMethod(methodName).invoke(adapter) Log.d(TAG, "NfcAdapter::$methodName() returned $result") + qsTile?.apply { + Log.d(TAG, "Updating tile") + state = if (!wasEnabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + if (SDK_INT >= Build.VERSION_CODES.Q) subtitle = getText( + if (!wasEnabled) string.tile_subtitle_active else string.tile_subtitle_inactive + ) + updateTile() + } if (result is Boolean && result) return // Success; return early. } catch (e: Exception) { Log.e(TAG, "Failed to invoke NfcAdapter::$methodName()", e) From afde76742065a7391496b3b0ff53966ae60ebdb2 Mon Sep 17 00:00:00 2001 From: Paul Colby Date: Fri, 21 Jun 2024 19:17:48 +1000 Subject: [PATCH 3/5] Refactor for better maintainability --- .../colby/nfcquicksettings/NfcTileService.kt | 125 +++++++++++++----- 1 file changed, 90 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt b/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt index 087b1b5..edcafc7 100644 --- a/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt +++ b/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt @@ -43,16 +43,7 @@ class NfcTileService : TileService() { val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(this) updateTimer = fixedRateTimer("default", false, 0L, 500) { Log.d(TAG, "updateTimer") - qsTile?.apply { - Log.d(TAG, "Updating tile") - state = if (adapter == null) Tile.STATE_INACTIVE else - if (adapter.isEnabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE - if (SDK_INT >= Build.VERSION_CODES.Q) subtitle = getText( - if (adapter == null) string.tile_subtitle_unavailable else - if (adapter.isEnabled) string.tile_subtitle_active else string.tile_subtitle_inactive - ) - updateTile() - } + updateTile(adapter) } } @@ -79,34 +70,61 @@ class NfcTileService : TileService() { override fun onClick() { super.onClick() Log.d(TAG, "onClick") + if (!invertNfcState()) startNfcSettingsActivity() + } + + /** + * Checks if [permission] has been granted to us. + * + * @return [true] if we have [permission], otherwise [false]. + */ + private fun permissionGranted(permission: String): Boolean { + val status = checkSelfPermission(permission) + Log.d(TAG, "$permission status: $status") + return status == PERMISSION_GRANTED + } + + /** + * Inverts the NFC [adapter]'s current state. + * + * That is, if [adapter] is currently enbled, then disable it, and vice versa. This calls + * [setNfcAdapterState], which in turn, requires WRITE_SECURE_SETTINGS permission. + * + * @return [true] if the [adapter]'s new state was successfully requested. + */ + private fun invertNfcState(adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(this)): Boolean { + return adapter?.run { setNfcAdapterState(this, !isEnabled) } ?: false + } - // Try using the WRITE_SECURE_SETTINGS permission to switch NFC on/off directly. - if (checkSelfPermission(WRITE_SECURE_SETTINGS) == PERMISSION_GRANTED) { - Log.i(TAG, "Have WRITE_SECURE_SETTINGS permission") - val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(this) - adapter?.apply { - val wasEnabled = adapter.isEnabled - val methodName = if (adapter.isEnabled) "disable" else "enable" - try { - Log.d(TAG, "Invoking NfcAdapter::$methodName()") - val result = NfcAdapter::class.java.getMethod(methodName).invoke(adapter) - Log.d(TAG, "NfcAdapter::$methodName() returned $result") - qsTile?.apply { - Log.d(TAG, "Updating tile") - state = if (!wasEnabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE - if (SDK_INT >= Build.VERSION_CODES.Q) subtitle = getText( - if (!wasEnabled) string.tile_subtitle_active else string.tile_subtitle_inactive - ) - updateTile() - } - if (result is Boolean && result) return // Success; return early. - } catch (e: Exception) { - Log.e(TAG, "Failed to invoke NfcAdapter::$methodName()", e) - } - } + /** + * Sets the [adapter] state to [enable]. + * + * This uses introspection to execute either the NfcAdapter::enable() or NfcAdapter::disable() + * function as appropriate. These functions both require WRITE_SECURE_SETTINGS permission. + * + * @return [true] if the state change was successfully requested, otherwise [false]. + */ + private fun setNfcAdapterState(adapter: NfcAdapter, enable: Boolean): Boolean { + if (!permissionGranted(WRITE_SECURE_SETTINGS)) return false + val methodName = if (enable) "enable" else "disable" + Log.i(TAG, "Setting NFC adapter's status to ${methodName}d") + val success = try { + Log.d(TAG, "Invoking NfcAdapter::$methodName()") + val result = NfcAdapter::class.java.getMethod(methodName).invoke(adapter) + Log.d(TAG, "NfcAdapter::$methodName() returned $result") + result is Boolean && result + } catch (e: Exception) { + Log.e(TAG, "Failed to invoke NfcAdapter::$methodName()", e) + false } + if (success) updateTile(enable) + return success + } - // Fall back to launching the NFC Settings action (doesn't require special permissions). + /** + * Starts the NFC Settings activity, then collapses the Quick Settings panel behind it. + */ + private fun startNfcSettingsActivity() { Log.i(TAG, "Starting the ACTION_NFC_SETTINGS activity") val intent = Intent(Settings.ACTION_NFC_SETTINGS) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK @@ -114,4 +132,41 @@ class NfcTileService : TileService() { if (SDK_INT < UPSIDE_DOWN_CAKE) @Suppress("DEPRECATION") startActivityAndCollapse(intent) else startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, FLAG_IMMUTABLE)) } + + /** + * Updates the Quick Settings tile with the [newState] and (if supported) [newSubTitleResId]. + * + * Note [newSubTitleResId] will be ignored on devices running Android versions earlier than Q. + * + * @param newState The next state for the tile. Should be one of the Tile.STATE_* constants. + * @param newSubTitleResId Resource ID for the new subtitle text. + */ + private fun updateTile(newState: Int, newSubTitleResId: Int) { + qsTile?.apply { + Log.d(TAG, "Updating tile") + this.state = newState + if (SDK_INT >= Build.VERSION_CODES.Q) this.subtitle = getText(newSubTitleResId) + updateTile() + } + } + + /** + * Updates the Quick Settings tile to show as active or not. + * + * @param active If [true] show the tile as active, otherwise show as inactive. + */ + private fun updateTile(active: Boolean) { + if (active) updateTile(Tile.STATE_ACTIVE, string.tile_subtitle_active) + else updateTile(Tile.STATE_INACTIVE, string.tile_subtitle_inactive) + } + + /** + * Updates the Quick Settings tile to reflect the [adapter]'s current state. + * + * @param adapter The adapter to reflect the state of. + */ + private fun updateTile(adapter: NfcAdapter?) { + adapter?.apply { updateTile(isEnabled) } ?: + updateTile(Tile.STATE_INACTIVE, string.tile_subtitle_unavailable) + } } From 38752d0ef23cc5bb205c1937dae8af8b465f5994 Mon Sep 17 00:00:00 2001 From: Paul Colby Date: Fri, 21 Jun 2024 22:14:51 +1000 Subject: [PATCH 4/5] Replace timer with broadcast listener For faster, more responsive, more efficient tile state updates. --- CHANGELOG.md | 3 + app/src/main/AndroidManifest.xml | 2 + .../colby/nfcquicksettings/NfcTileService.kt | 106 +++++++++++------- 3 files changed, 73 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8641dea..c32fab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased][] +Replaced timer with broadcast listener for more responsive tile updates. +Added support for direct NFC toggle if granted `WRITE_SECURE_SETTINGS` permission. + ## [1.3.1][] (2023-12-04) Enabled code minification and resource shrinkage. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index edf2ff7..7c2930f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,8 @@ + diff --git a/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt b/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt index edcafc7..d08488e 100644 --- a/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt +++ b/app/src/main/java/au/id/colby/nfcquicksettings/NfcTileService.kt @@ -6,19 +6,21 @@ package au.id.colby.nfcquicksettings import android.Manifest.permission.WRITE_SECURE_SETTINGS import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager.PERMISSION_GRANTED import android.nfc.NfcAdapter -import android.os.Build import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE import android.provider.Settings import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log +import androidx.core.content.ContextCompat import au.id.colby.nfcquicksettings.R.string -import java.util.Timer -import kotlin.concurrent.fixedRateTimer private const val TAG = "NfcTileService" @@ -29,43 +31,42 @@ private const val TAG = "NfcTileService" * device's NFC Settings activity. */ class NfcTileService : TileService() { - private var updateTimer: Timer? = null + private val nfcBroadcastReceiver = NfcBroadcastReceiver() /** * Called when this tile moves into a listening state. * - * This override updates the tile's state and title to indicate the device's current NFC status - * (On, Off, or Unavailable). + * This override registers a broadcast receiver to listen for NFC adapter state changes, then + * updates the tile according the default adapter's current state. */ override fun onStartListening() { super.onStartListening() - Log.d(TAG, "onStartListening") - val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(this) - updateTimer = fixedRateTimer("default", false, 0L, 500) { - Log.d(TAG, "updateTimer") - updateTile(adapter) - } + Log.d(TAG, "onStartListening; Registering broadcast receiver") + ContextCompat.registerReceiver( + this, + nfcBroadcastReceiver, + IntentFilter(NfcAdapter.ACTION_ADAPTER_STATE_CHANGED), + ContextCompat.RECEIVER_EXPORTED + ) + updateTile() } /** * Called when this tile moves out of the listening state. * - * This override cancels the update timer, if any is running. + * This override simply unregisters the broadcast receiver. */ override fun onStopListening() { - Log.d(TAG, "onStopListening") - updateTimer?.apply { - Log.d(TAG, "Cancelling update timer") - cancel() - } + Log.d(TAG, "onStopListening; Unregistering broadcast receiver") + unregisterReceiver(nfcBroadcastReceiver) super.onStopListening() } /** * Called when the user clicks on this tile. * - * This override takes the user to the NFC settings, by starting the `ACTION_NFC_SETTINGS` - * activity, and collapsing the Quick Settings menu. + * This override attempts to invert the default NFC adapter's state, and if that cannot be done, + * launches the NFC Settings Action, where the user can toggle it themselves. */ override fun onClick() { super.onClick() @@ -73,36 +74,36 @@ class NfcTileService : TileService() { if (!invertNfcState()) startNfcSettingsActivity() } - /** - * Checks if [permission] has been granted to us. - * - * @return [true] if we have [permission], otherwise [false]. - */ - private fun permissionGranted(permission: String): Boolean { - val status = checkSelfPermission(permission) - Log.d(TAG, "$permission status: $status") - return status == PERMISSION_GRANTED - } - /** * Inverts the NFC [adapter]'s current state. * - * That is, if [adapter] is currently enbled, then disable it, and vice versa. This calls + * That is, if [adapter] is currently enabled, then disable it, and vice versa. This calls * [setNfcAdapterState], which in turn, requires WRITE_SECURE_SETTINGS permission. * - * @return [true] if the [adapter]'s new state was successfully requested. + * @return true if the [adapter]'s new state was successfully requested. */ private fun invertNfcState(adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(this)): Boolean { return adapter?.run { setNfcAdapterState(this, !isEnabled) } ?: false } + /** + * Checks if [permission] has been granted to us. + * + * @return true if we have [permission], otherwise false. + */ + private fun permissionGranted(permission: String): Boolean { + val status = checkSelfPermission(permission) + Log.d(TAG, "$permission status: $status") + return status == PERMISSION_GRANTED + } + /** * Sets the [adapter] state to [enable]. * * This uses introspection to execute either the NfcAdapter::enable() or NfcAdapter::disable() * function as appropriate. These functions both require WRITE_SECURE_SETTINGS permission. * - * @return [true] if the state change was successfully requested, otherwise [false]. + * @return true if the state change was successfully requested, otherwise false. */ private fun setNfcAdapterState(adapter: NfcAdapter, enable: Boolean): Boolean { if (!permissionGranted(WRITE_SECURE_SETTINGS)) return false @@ -143,9 +144,9 @@ class NfcTileService : TileService() { */ private fun updateTile(newState: Int, newSubTitleResId: Int) { qsTile?.apply { - Log.d(TAG, "Updating tile") + Log.d(TAG, "Updating tile with state $newState") this.state = newState - if (SDK_INT >= Build.VERSION_CODES.Q) this.subtitle = getText(newSubTitleResId) + if (SDK_INT >= VERSION_CODES.Q) this.subtitle = getText(newSubTitleResId) updateTile() } } @@ -153,7 +154,7 @@ class NfcTileService : TileService() { /** * Updates the Quick Settings tile to show as active or not. * - * @param active If [true] show the tile as active, otherwise show as inactive. + * @param active If true show the tile as active, otherwise show as inactive. */ private fun updateTile(active: Boolean) { if (active) updateTile(Tile.STATE_ACTIVE, string.tile_subtitle_active) @@ -165,8 +166,37 @@ class NfcTileService : TileService() { * * @param adapter The adapter to reflect the state of. */ - private fun updateTile(adapter: NfcAdapter?) { + private fun updateTile(adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(this)) { adapter?.apply { updateTile(isEnabled) } ?: updateTile(Tile.STATE_INACTIVE, string.tile_subtitle_unavailable) } + + /** + * Provides static functions for the NfcTileService class. + */ + companion object { + /** + * Updates the Quick Settings tile owned by [context], which is an NfcTileService instance. + */ + fun updateTile(context: Context) { + (context as? NfcTileService)?.run { updateTile(); } + } + } + + /** + * A custom Broadcast Receiver for updating an NfcTileService on NFC adapter state changes. + */ + inner class NfcBroadcastReceiver : BroadcastReceiver() { + /** + * Called when a broadcast message is received. + * + * This override simply reflects the event back to the [context] for which it the + * receiver was registered. + */ + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "onReceive $context $intent") + if (intent.action == NfcAdapter.ACTION_ADAPTER_STATE_CHANGED) updateTile(context) + else Log.w(TAG, "Received unexpected broadcast message: $intent") + } + } } From 16a0bb9a4469d8c6bbc2cad6b551fdccef35bdc1 Mon Sep 17 00:00:00 2001 From: Paul Colby Date: Fri, 21 Jun 2024 22:16:45 +1000 Subject: [PATCH 5/5] A small CHANGELOG.md layout tweak --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c32fab2..7258aa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased][] Replaced timer with broadcast listener for more responsive tile updates. + Added support for direct NFC toggle if granted `WRITE_SECURE_SETTINGS` permission. ## [1.3.1][] (2023-12-04)