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,6 +6,28 @@

(尚無項目)

## [1.5.0] - 2026-08-30

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

### 新增(Added)

- **執行中的流程看得見了,不管是誰啟動的**:卡片右下角在流程執行時轉成轉圈的「執行中」。這裡原本是點下執行鈕才亮三秒的計時器,所以側滑執行、時間到、地理圍欄、小工具啟動的流程一律不亮,而被權限擋下、根本沒開始的執行反而亮著。現在的來源是引擎自己用來擋重複執行的那份狀態,不是另一份會走樣的複本
- 執行極快的流程會至少顯示 0.9 秒。三十毫秒跑完的流程是使用者真的要求過的一次執行,但指示器在一個影格內閃掉,和什麼都沒發生分不出來——而那正是這個指示器存在的理由
- **通知會說出正在跑哪一個流程**:常駐通知從「自動化執行中」變成「正在執行:<流程名稱>」,同時跑多個時顯示數量、展開列出名稱。背景觸發的流程原本只有一個容易錯過的 Toast 作為線索
- **長按右上角的閃電,列出目前執行中的流程**:從膠囊邊緣展開的浮層,每一列帶自己的轉圈指示。膠囊本身也會顯示執行中的數量,所以不長按也看得到

### 修正(Fixed)

- **排程到 9:00 的流程可能 9:10 才跑**:`SCHEDULE_EXACT_ALARM` 在 manifest 裡宣告了,但**沒有任何一處程式碼向使用者要過它**。targetSdk 33 以上的 App 在全新安裝(以及備份還原到新機)時這個權限預設是拒絕的,於是排程器安靜地退回不精確鬧鐘,被系統批次進 Doze 維護視窗——官方文件對不精確鬧鐘的保證是「一小時內」。從舊版一路升級上來的裝置會保留已授予的權限,這是為什麼同一版有人遇到、有人沒有
- TIME 觸發器納入權限檢查:缺少精確鬧鐘時,流程卡片會亮出警告,設置向導把使用者帶到系統的「鬧鐘和提醒」頁
- 使用者授予後,`SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` 會立刻重排所有 TIME 鬧鐘。鬧鐘只在排定當下決定精度,沒有這一步,授權後既有的鬧鐘會繼續遲到,直到使用者重新編輯流程或重開機
- **NexFlow 開著的時候,整支手機的 NFC 都失效**:`MainActivity` 無條件在 `onResume` 開啟 reader mode。reader mode 是獨占且全裝置生效的,`FLAG_READER_SKIP_NDEF_CHECK` 更直接關掉 NDEF 分派,所以感應網址標籤不會開啟瀏覽器、感應交通卡沒有反應——即使使用者一個 NFC 流程都沒有。現在只有「有啟用中的 NFC 流程且引擎正在執行」或「正在設定頁掃描標籤」時才佔用
- 一併移除 manifest 的 `TAG_DISCOVERED` 攔截。它是標籤分派的最後手段,等於讓 NexFlow 成為所有其他 App 都不處理的標籤的萬用接收者(門禁卡、交通卡);它在 API 37 也已經被標為 deprecated。NFC 觸發器本來就只在前景有效,從來不需要這個攔截
- 掃描中讀到的標籤不再同時觸發流程。重新掃描「正在編輯的那個流程」所綁定的標籤,不應該把那個流程跑起來
- 設定頁的掃描不再自己開關 reader mode。兩個持有者的結果是設定頁關閉掃描時,順手把 `MainActivity` 為了 NFC 觸發器而持有的那份也關掉了
- **音量動作的摘要顯示成「媒體:?」**:1.4.0 把音量從絕對階數(`level`)改存百分比(`volume_percent`),但流程詳情頁的摘要那一行仍然去讀舊的鍵。所有音量動作——包括用新版編輯器存的——都顯示成問號。摘要現在走與編輯器相同的正規化,1.4.0 之前寫的流程不必重新儲存也會顯示它實際會執行的百分比

## [1.4.0] - 2026-08-21

這一版補的是「跑完之後,到底發生了什麼」:執行記錄從一句成敗變成逐個動作的紀錄,HTTP 拿得回資料讓流程能依真實回應分支,而條件判斷不再對「從來沒有的值」下結論。同時把工具鏈與相依套件推到現行 stable,並讓測試從一層變四層、全部在 CI 上把關。
Expand Down Expand Up @@ -227,6 +249,7 @@ AI 對話從「能用」變成「好用」:回覆逐字出現而不是等半
- 多語言:繁體中文、簡體中文、日文、英文
- 匯入/匯出 `.flow`(JSON)與 MacroDroid `.mdr` 相容解析

[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
[1.2.0]: https://github.com/ADSFAaron/NexFlow/releases/tag/v1.2.0
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ core/macrodroid-compat/ # MacroDroid .mdr 格式解析
| `Regex("""\{\{([^}]+)}}""")` | 某些 Android regex engine 對 `))}}` 報 syntax error | 改為 `\}\}` 明確 escape |
| `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 |
| `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
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 = 8
versionName = "1.4.0"
versionCode = 9
versionName = "1.5.0"

testInstrumentationRunner = "com.nexflow.HiltTestRunner"
}
Expand Down
21 changes: 16 additions & 5 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,12 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- NFC tag dispatch — receives NFC intents when app is in foreground -->
<intent-filter>
<action android:name="android.nfc.action.TAG_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Deliberately NO NFC intent filter. TAG_DISCOVERED is the tag dispatch
system's last resort, so registering it made NexFlow the catch-all handler for
every tag no other app claimed — office badges, transit cards and URL tags all
launched NexFlow instead of doing what the user expected. It is also deprecated
as of API 37. NFC triggers only fire while the app is in the foreground
(MainActivity's reader mode), which never needed this filter. -->
<!-- Receive shared NexFlow JSON (from another device's Export button) -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
Expand Down Expand Up @@ -154,6 +155,16 @@
</intent-filter>
</receiver>

<!-- Re-schedule TIME alarms as exact ones the moment the user grants
"Alarms & reminders". Not exported: the system sends this broadcast directly. -->
<receiver
android:name=".receiver.ExactAlarmPermissionReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
</intent-filter>
</receiver>

<!-- Overview widget (4×2): shows recent flows with run buttons -->
<receiver
android:name=".widget.NexFlowWidgetReceiver"
Expand Down
87 changes: 69 additions & 18 deletions app/src/main/java/com/nexflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.nexflow.core.automation.model.TriggerType
import com.nexflow.core.automation.repository.FlowRepository
import com.nexflow.event.ImportEventSource
import com.nexflow.event.NfcEventSource
import com.nexflow.event.NfcReaderMode
import com.nexflow.prefs.OnboardingPrefs
import com.nexflow.prefs.ServiceEnabledPrefs
import com.nexflow.service.FlowExecutionService
Expand All @@ -43,15 +49,23 @@ import com.nexflow.ui.navigation.NexFlowNavHost
import com.nexflow.ui.onboarding.OnboardingScreen
import com.nexflow.ui.theme.NexFlowTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

@Inject lateinit var repository: FlowRepository

private var nfcAdapter: NfcAdapter? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
observeReaderModeNeed()
// Respect the persistent master switch: if the user stopped the automation
// service, opening the app must not silently restart it.
if (ServiceEnabledPrefs.get(this)) {
Expand Down Expand Up @@ -88,31 +102,67 @@ class MainActivity : AppCompatActivity() {
}
}
}
handleNfcIntent(intent)
handleShareIntent(intent)
handleRunFlowIntent(intent)
}

override fun onResume() {
super.onResume()
nfcAdapter?.enableReaderMode(
this,
{ tag -> dispatchNfcTag(tag) },
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
null,
)
/**
* Hold the NFC controller in reader mode only while something actually needs it.
*
* This used to be an unconditional `enableReaderMode` in `onResume`, which meant that
* merely having NexFlow open swallowed every tap on the device: reader mode is exclusive
* and FLAG_READER_SKIP_NDEF_CHECK disables NDEF dispatch outright, so a URL tag opened
* nothing and a transit card did nothing — even for users with no NFC flow at all.
*
* Collection is scoped to RESUMED because reader mode is only legal for a foreground
* activity, which is also the only time an NFC trigger can fire.
*/
private fun observeReaderModeNeed() {
val adapter = nfcAdapter ?: return
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
combine(
repository.observeEnabled().map { flows ->
flows.any { flow -> flow.triggers.any { it.type == TriggerType.NFC_TAG } }
},
FlowExecutionService.running,
NfcReaderMode.scanRequests.map { it > 0 },
) { hasNfcFlow, engineRunning, scanning ->
// A tag can only start a flow when the engine is actually up, so with the
// master switch off there is nothing to gain from claiming the controller.
// A scan is exempt: naming a tag is how the first NFC flow gets configured.
(hasNfcFlow && engineRunning) || scanning
}
.distinctUntilChanged()
.collect { needed ->
if (needed) {
adapter.enableReaderMode(
this@MainActivity,
{ tag -> dispatchNfcTag(tag) },
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
null,
)
} else {
adapter.disableReaderMode(this@MainActivity)
}
}
}
}
}

override fun onPause() {
super.onPause()
// Unconditional, and before onPause returns: the collector above is cancelled by the
// lifecycle without getting a chance to turn reader mode off itself, and leaving it on
// would keep NFC hijacked after the app is no longer in front. A no-op when it was
// never enabled.
nfcAdapter?.disableReaderMode(this)
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleNfcIntent(intent)
handleShareIntent(intent)
handleRunFlowIntent(intent)
}
Expand Down Expand Up @@ -155,13 +205,14 @@ class MainActivity : AppCompatActivity() {
}
}

private fun handleNfcIntent(intent: Intent?) {
val tag = intent?.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return
dispatchNfcTag(tag)
}

private fun dispatchNfcTag(tag: Tag) {
val tagId = tag.id.joinToString("") { "%02X".format(it) }
NfcEventSource.emit(tagId)
// A scan in progress claims the tag outright. The user is naming a tag, not asking for
// the flows bound to it to run — and one of those is very likely the flow being edited.
if (NfcReaderMode.scanRequests.value > 0) {
NfcReaderMode.emitScannedTag(tagId)
} else {
NfcEventSource.emit(tagId)
}
}
}
67 changes: 67 additions & 0 deletions app/src/main/java/com/nexflow/event/NfcReaderMode.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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.event

import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

/**
* Lets UI ask for the NFC controller to be put in reader mode without touching the adapter.
*
* Reader mode is exclusive and device-wide: while it is on, every tap goes to this app's
* callback and nothing is dispatched to any other app — a URL tag opens nothing, a transit
* card does nothing. So it must have exactly one owner ([com.nexflow.MainActivity]) that can
* see every reason it is wanted, and it must be off whenever there is no reason at all.
*
* Two things want it: an enabled flow with an NFC trigger (the activity works that out from
* the repository), and a config screen waiting for the user to tap the tag they are naming —
* which has to work even before any NFC flow exists. Requests are counted rather than a plain
* flag so one screen ending its scan cannot switch off another screen's.
*/
object NfcReaderMode {

private val _scanRequests = MutableStateFlow(0)

/** How many screens are currently waiting for the user to tap a tag. */
val scanRequests: StateFlow<Int> = _scanRequests.asStateFlow()

fun beginScan() = _scanRequests.update { it + 1 }

fun endScan() = _scanRequests.update { (it - 1).coerceAtLeast(0) }

private val _scannedTags = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

/**
* Tags read while a scan is in progress. Separate from [NfcEventSource] on purpose: a tag
* tapped to fill in a config field must not also fire the flows that trigger on it —
* otherwise re-scanning the tag of the flow you are editing runs that very flow.
*/
val scannedTags: SharedFlow<String> = _scannedTags.asSharedFlow()

fun emitScannedTag(tagId: String) {
_scannedTags.tryEmit(tagId)
}
}
Loading
Loading