Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,30 @@

(尚無項目)

## [1.5.1] - 2026-08-30

把 1.5.0 為了修好「NFC 被獨占」而一併拿掉的背景感應補回來,但這次只發給真的需要的人。

### 新增(Added)

- **關著 App 也能感應 NFC 標籤觸發流程**。1.5.0 移除了 manifest 的 `TAG_DISCOVERED` 攔截——它是「所有其他 App 都不處理的標籤我全收」,門禁卡、交通卡因此都會把 NexFlow 叫起來。代價是背景感應一起沒了。現在改成 `<activity-alias>` 承載攔截、預設關閉,只在「有啟用中的 NFC 流程」時才由程式打開:
- **沒有 NFC 流程的使用者,NexFlow 完全不會出現在標籤分派名單裡**。被停用的元件不參與 intent 解析,所以不是「排在後面」,是根本不在
- 感應後開啟的是一個透明、無介面、跑完即關的 Activity,畫面上不會彈出任何東西
- 改用 `TECH_DISCOVERED` 而非已被 API 37 標為 deprecated 的 `TAG_DISCOVERED`。tech-list 只列 `Ndef` 與 `NdefFormatable`:`Ndef` 是官方文件唯一保證「所有支援 NFC 的 Android 裝置都必須正確列舉」的技術,而 `MifareClassic`/`MifareUltralight` 是選配的——列了它們會在非 NXP 晶片的手機上安靜地永遠不匹配。這份清單同時把抱怨來源擋在外面:交通卡與門禁卡是 MifareClassic、感應支付是 IsoDep、Suica 是 NfcF,都不會列舉 `Ndef`
- 引擎停止時交還標籤分派。自動化關掉時攔下感應卻什麼都不做,等於白白吃掉使用者的一次感應
- 標籤在引擎訂閱之前抵達時會先保留再送出。標籤喚醒的是一個已經死掉的程序,`NfcEventSource` 原本 `replay = 0`,那一次 emit 會落空——結果是 App 被叫醒、流程沒跑

### 已知限制

- 寫入網址的標籤仍然會開啟瀏覽器:`NDEF_DISCOVERED` 的優先權在 `TECH_DISCOVERED` 之上,這是對的。空白或只用 UID 的標籤(NexFlow NFC 觸發器的實際用法)不受影響
- 非 NDEF 標籤(門禁卡、交通卡當觸發器)在背景不會觸發。App 開著時 reader mode 仍然讀得到所有標籤

## [1.5.0] - 2026-08-30

> **未上架 Google Play。** 這一版的內容隨 1.5.1 一起發布,商店的使用者是從 1.4.0
> 直接更新到 1.5.1,所以 Play 的更新資訊涵蓋兩版(見
> [docs/play-release-notes.md](docs/play-release-notes.md))。

這一版處理的是「我怎麼知道它有沒有在動」,以及兩個安靜到不像故障的故障。執行中的流程現在會在卡片、通知與長按閃電的清單裡同時現身;排程晚到十分鐘的原因是一個從來沒被要求過的權限;而 NexFlow 只要開著,就會把整支手機的 NFC 感應全部吃掉。

### 新增(Added)
Expand Down Expand Up @@ -249,6 +271,7 @@ AI 對話從「能用」變成「好用」:回覆逐字出現而不是等半
- 多語言:繁體中文、簡體中文、日文、英文
- 匯入/匯出 `.flow`(JSON)與 MacroDroid `.mdr` 相容解析

[1.5.1]: https://github.com/ADSFAaron/NexFlow/releases/tag/v1.5.1
[1.5.0]: https://github.com/ADSFAaron/NexFlow/releases/tag/v1.5.0
[1.4.0]: https://github.com/ADSFAaron/NexFlow/releases/tag/v1.4.0
[1.3.0]: https://github.com/ADSFAaron/NexFlow/releases/tag/v1.3.0
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ core/macrodroid-compat/ # MacroDroid .mdr 格式解析
| `AccessibilityService.dispatchGesture()` | 沒在 accessibility-service XML 宣告 `android:canPerformGestures="true"` 就只會回 `false`,不丟例外、不寫 log,模擬點擊/滑動整個靜默失效 | `nexflow_accessibility_config.xml` 一定要有 `android:canPerformGestures="true"` |
| `GestureDescription.StrokeDescription` | 座標超出螢幕範圍會丟 `IllegalArgumentException`;座標來自使用者手打的設定,在 service 的 coroutine 裡丟出去會拖垮整個無障礙服務 | 建 `StrokeDescription` 要包 `runCatching`,失敗就回報 action 失敗。單點 `moveTo`(不 `lineTo`)是合法的「不移動的觸碰」,可直接當點擊用 |
| `NfcAdapter.enableReaderMode()` | **獨占且全裝置生效**:開著的時候所有感應都只進自己的 callback,別的 App 一律看不到標籤;`FLAG_READER_SKIP_NDEF_CHECK` 更直接關掉 NDEF 分派(javadoc 原話「NDEF-based tag dispatch will not be functional」)。在 `onResume` 無條件開啟=只要 App 開著就吃掉使用者的網址標籤與交通卡 | 只在真的要用時開(有啟用中的 NFC 流程、或正在掃描設定),且只能有一個持有者。設定頁想掃卡就向那個持有者提出請求,不要自己 `enableReaderMode`——第二個持有者關閉時會把第一個的也關掉 |
| manifest `android.nfc.action.TAG_DISCOVERED` | 分派順序是 NDEF → TECH → TAG,TAG 是最後手段。若機器上只有你註冊,就成了所有其他 App 不處理的標籤(門禁卡、交通卡)的萬用接收者。API 37 已標 `@Deprecated` | 前景 NFC 用 reader mode 就夠,不要註冊這個 filter。真要背景 NFC 才用 `TECH_DISCOVERED` + 明確 tech-list |
| manifest `android.nfc.action.TAG_DISCOVERED` | 分派順序是 NDEF → TECH → TAG,TAG 是最後手段。若機器上只有你註冊,就成了所有其他 App 不處理的標籤(門禁卡、交通卡)的萬用接收者。API 37 已標 `@Deprecated` | 背景 NFC 用 `TECH_DISCOVERED` + 明確 tech-list,並把 filter 放在預設 `enabled="false"` 的 `<activity-alias>` 上,用 `setComponentEnabledSetting` 只在真的有 NFC 流程時打開——被停用的元件不參與 intent 解析。見 `NfcBackgroundDispatch` |
| NFC `tech-list` 匹配語意 | **反直覺**:官方定義是「tech-list 是標籤所報技術的 subset 才算匹配」,所以**列愈少技術匹配愈廣**。同一個 `<tech-list>` 內是 AND,多個 `<tech-list>` 之間是 OR。很多人以為列一個就是「只收這種」,其實是「所有帶這種的都收」 | 想收窄就多列幾個技術;想分開涵蓋就開多個 `<tech-list>` |
| `android.nfc.tech.Mifare*` 放進 tech-list | `MifareClassic`/`MifareUltralight` 的實作在 Android 上是**選配**的(javadoc:「If it is not implemented, then MifareClassic will never be enumerated in `getTechList`」),非 NXP 晶片的手機上永遠不會出現 → tech-list 安靜地永不匹配,不報錯也沒 log | tech-list 只用 `Ndef`/`NdefFormatable`/`NfcA`~`NfcV`。`Ndef` 是唯一「所有支援 NFC 的裝置都必須正確列舉」的,跨機一致 |
| `SCHEDULE_EXACT_ALARM` | targetSdk 33+ 的 App **全新安裝時預設拒絕**(備份還原到新機也是拒絕),但從舊版升級的裝置會保留已授予的 → 同一版有人正常有人遲到。沒授權時退回不精確鬧鐘,官方保證只有「一小時內」,排 9:00 的流程 9:10 才跑 | 宣告不等於拿到:一定要 `canScheduleExactAlarms()` 檢查 + 用 `ACTION_REQUEST_SCHEDULE_EXACT_ALARM` 引導。並且要收 `ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` 重排——鬧鐘的精度是排定當下決定的,授權後既有鬧鐘不會自己升級 |

### 3. 測試
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,17 +349,17 @@ adb shell settings put global animator_duration_scale 1.0
| 亮度調整 | 修改系統設定(WRITE_SETTINGS) |
| 勿擾模式 | 勿擾存取權限 |
| Wi-Fi/飛航模式靜默切換 | `WRITE_SECURE_SETTINGS`(需透過 ADB 授權一次,App 內有指令可複製) |
| NFC 觸發 | App 在前景時有效 |
| NFC 觸發 | App 開著時皆可;關著時僅限 NDEF 標籤,且需有啟用中的 NFC 流程 |

## 已知限制(規劃中)

- 全域變數需先在「設定 → 全域變數」建立才能寫入:`SET_VARIABLE` 不會用打錯的 `g:名稱` 自動建立新變數(避免 typo 悄悄產生殭屍變數)。設定框會擋下不存在的 `g:` 名稱、無法儲存;萬一仍寫入(例如匯入的舊檔),執行會**失敗並在執行記錄寫出該名稱**,而不是靜默跳過
- **MacroDroid 相容為部分覆蓋**:目前對照 22 種觸發、30 種動作、7 種條件的 MacroDroid class type(MacroDroid 本身有上百種),其餘一律轉成 `UNSUPPORTED` 並在匯入時逐項列出警告(原始 class 名會保留在 config 裡,方便手動補上對應動作)。設定欄位有 46 種 class 的對照,沒有對照或對不過去的欄位同樣會逐項寫進警告,不會靜默消失。轉換是 best-effort,複雜巨集匯入後請先檢視再啟用 —— 已知一定轉不過來的項目(地理圍欄座標、選單各選項的動作、捷徑、桌布圖片…)列在 [docs/MACRODROID_IMPORT.md](docs/MACRODROID_IMPORT.md)
- **精確時間需要「鬧鐘與提醒」權限**:TIME 觸發走 `AlarmManager`,Android 12+ 未授權 `SCHEDULE_EXACT_ALARM` 時會退回不精確排程 —— 仍能穿透 Doze,但系統可能併入維護視窗,**誤差數分鐘**。設定頁有引導前往授權
- **精確時間需要「鬧鐘與提醒」權限**:TIME 觸發走 `AlarmManager`,Android 12+ 未授權 `SCHEDULE_EXACT_ALARM` 時會退回不精確排程 —— 仍能穿透 Doze,但系統可能併入維護視窗,**誤差可達一小時**(官方對不精確鬧鐘的保證是「一小時內」)。未授權時**流程卡片會亮出警告**並帶你前往授權,授權後既有排程會立即重新校正
- **省電機制可能中斷非時間類觸發**:搖晃、環境光、Wi-Fi/藍牙、耳機、螢幕等觸發依附於前景服務的事件串流,被系統或廠商的省電策略殺掉後就會停止監聽(TIME 因為由 AlarmManager 驅動不受影響)。目前**還沒有引導使用者把 App 加入電池最佳化白名單**的畫面,激進省電的機型請自行到系統設定放行
- **同一流程不會並行執行**:正在執行的流程再次被觸發會直接略過(避免重複觸發疊加成多份同時執行),這些觸發**不會排隊補跑**
- **Wi-Fi/飛航模式靜默切換需一次性 ADB 授權**:Android 10 起系統不再開放第三方 App 直接切換,必須手動授予 `WRITE_SECURE_SETTINGS`(App 內可複製指令);未授權時只能改為跳轉系統設定頁
- **NFC 觸發僅在 App 前景時有效**:使用 `enableReaderMode`,背景不會接收標籤
- **NFC 背景觸發只涵蓋 NDEF 標籤**:App 開著時走 `enableReaderMode`,什麼標籤都讀得到。關著時走 `TECH_DISCOVERED`,tech-list 只列 `Ndef`/`NdefFormatable` —— 交通卡、門禁卡(MifareClassic)與感應支付(IsoDep)不會列舉 `Ndef`,所以**背景不會觸發**,這是刻意的:那份攔截若放寬,NexFlow 就會變成這些卡片的萬用接收者。另外,**寫入網址的標籤仍會開啟瀏覽器**(`NDEF_DISCOVERED` 優先權在上)。背景攔截掛在預設關閉的 `<activity-alias>` 上,只有在你有啟用中的 NFC 流程時才會打開——沒有的話 NexFlow 完全不出現在標籤分派名單裡

## 參與貢獻

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ android {
applicationId = "com.adsf.nexflow"
minSdk = 30
targetSdk = 37
versionCode = 9
versionName = "1.5.0"
versionCode = 10
versionName = "1.5.1"

testInstrumentationRunner = "com.nexflow.HiltTestRunner"
}
Expand Down
28 changes: 28 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,34 @@
</intent-filter>
</receiver>

<!-- Where a tag lands when the app is closed: invisible, runs the flow, finishes.
Not exported — only the alias below is reachable from outside. -->
<activity
android:name=".nfc.NfcTagRunActivity"
android:exported="false"
android:taskAffinity=""
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@style/Theme.NexFlow.Transparent" />

<!-- NFC tag dispatch, shipped DISABLED and switched on by NfcBackgroundDispatch only
while an enabled flow has an NFC trigger. A plain manifest filter is permanent, and
that is what made the old TAG_DISCOVERED entry hijack every unclaimed tag for users
who had no NFC flow at all. See res/xml/nfc_tech_filter.xml for why the tech-list is
Ndef-only. -->
<activity-alias
android:name=".nfc.NfcTagDispatchAlias"
android:targetActivity=".nfc.NfcTagRunActivity"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED" />
</intent-filter>
<meta-data
android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
</activity-alias>

<!-- Re-schedule TIME alarms as exact ones the moment the user grants
"Alarms & reminders". Not exported: the system sends this broadcast directly. -->
<receiver
Expand Down
30 changes: 26 additions & 4 deletions app/src/main/java/com/nexflow/event/NfcEventSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,45 @@
package com.nexflow.event

import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.onSubscription
import java.util.concurrent.atomic.AtomicReference

/**
* Bridges MainActivity NFC intent dispatch → NfcTagTriggerHandler.
* NFC triggers only fire while the app activity is in the foreground (Android limitation).
* Bridges a scanned NFC tag → NfcTagTriggerHandler.
*
* Two things scan: MainActivity's reader mode while the app is in front, and
* [com.nexflow.nfc.NfcTagRunActivity] when a tag wakes the app from closed.
*/
object NfcEventSource {
private val _events = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val events = _events.asSharedFlow()

/**
* A tag scanned before anything was listening. A tag arriving on a dead process starts the
* service and emits in the same breath, and the engine cannot have subscribed yet — with
* `replay = 0` that emit lands nowhere, so the tag would wake the app and then do nothing.
* Held here instead and delivered to the first subscriber.
*/
private val pending = AtomicReference<String?>(null)

val events: Flow<String> = _events.asSharedFlow()
.onSubscription { pending.getAndSet(null)?.let { emit(it) } }

/** @param tagId hex string of the NFC tag UID, e.g. "04A3B2C1" */
fun emit(tagId: String) {
_events.tryEmit(tagId)
// Checked rather than always buffered: with a live collector the tag must go straight
// through, and stashing it as well would fire the flow twice.
if (_events.subscriptionCount.value == 0) pending.set(tagId) else _events.tryEmit(tagId)
}

/** Drops a tag held for a subscriber that never came. */
fun clearPending() {
pending.set(null)
}
}
108 changes: 108 additions & 0 deletions app/src/main/java/com/nexflow/nfc/NfcBackgroundDispatch.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright 2026 NexFlow Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.nexflow.nfc

import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import com.nexflow.core.automation.model.Flow
import com.nexflow.core.automation.model.TriggerType
import com.nexflow.event.NfcEventSource
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton

/**
* Registers NexFlow for NFC tag dispatch only while a flow could actually use a tag.
*
* A manifest intent filter is normally all-or-nothing, and that is what made the previous
* `TAG_DISCOVERED` filter a problem: every user was permanently registered as the handler for
* every tag no other app claimed, whether or not they had ever built an NFC flow. An
* `<activity-alias>` shipped disabled and toggled here is the way to make the filter
* conditional — a disabled component is excluded from intent resolution entirely, so NexFlow
* does not appear in tag dispatch at all until [sync] turns it on.
*
* Enabled state survives reboots and updates, so both directions have to be written: leaving it
* on after the last NFC flow is deleted would keep intercepting tags for a feature the user no
* longer has.
*/
@Singleton
class NfcBackgroundDispatch @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
private val alias = ComponentName(context, ALIAS_CLASS)

/** Enable dispatch when any enabled flow has an NFC trigger, disable it otherwise. */
fun sync(flows: List<Flow>) {
val wanted = flows.any { flow ->
flow.enabled && flow.triggers.any { it.type == TriggerType.NFC_TAG }
}
setEnabled(wanted)
}

/**
* Give up tag dispatch. Called when the engine stops: with automation off a tag would wake
* the app, find nothing listening and do nothing visible — an intercepted tap that costs the
* user their tag read and returns nothing.
*/
fun disable() {
setEnabled(false)
// Nothing will collect it now, and a stale id delivered whenever the engine next starts
// would fire a flow the user triggered minutes or days ago.
NfcEventSource.clearPending()
}

private fun setEnabled(enabled: Boolean) {
val target = if (enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
// Read first: this is a cross-process call that writes to package state, and sync() runs
// on every change to the flow list.
if (runCatching { context.packageManager.getComponentEnabledSetting(alias) }
.getOrNull() == target
) {
return
}
try {
context.packageManager.setComponentEnabledSetting(
alias,
target,
// Without DONT_KILL_APP the platform restarts the process to apply this — which,
// called from the running engine, would kill the service mid-flow.
PackageManager.DONT_KILL_APP,
)
} catch (e: IllegalArgumentException) {
// The alias is addressed by a string, so a rename in the manifest lands here rather
// than at compile time. Swallowing it silently would mean background NFC simply
// never works, with nothing to show why. NfcBackgroundDispatchTest asserts the name
// resolves against the merged manifest so this should be unreachable.
Log.e(TAG, "NFC dispatch alias $ALIAS_CLASS is not declared in the manifest", e)
}
}

private companion object {
const val TAG = "NfcBackgroundDispatch"

/**
* The alias is manifest-only (no Kotlin class of its own), so it is addressed by name.
* Must match the `android:name` of the activity-alias in AndroidManifest.xml.
*/
const val ALIAS_CLASS = "com.nexflow.nfc.NfcTagDispatchAlias"
}
}
Loading
Loading