淺談「Bluetooth」的權限管理與掃描機制
目的
了解如何在「Android」系統開發藍牙功能與其該具備的相關知識。
概要
「Bluetooth」,中文翻譯為「藍牙」,是一種無線通訊技術標準,是目前主流的通訊標準之一,廣泛地應用在「IOT」領域。
事實上,早期的藍牙由於規格的紊亂與技術的限制,在應用上並不理想,但隨著時間發展,藍牙的技術已趨於成熟,並且目前有關於藍牙的標準、規格皆由「藍牙技術聯盟」統一制定與維護。
備註:藍牙技術聯盟,英文全名為「Bluetooth Special Interest Group」,縮寫為「SIG」。
而本文要介紹的就是如何在 Android OS 上實作藍牙功能與應用。
正文
藍牙功能大致可以根據規格區分為兩大類,分別為「傳統藍牙:Bluetooth,BT」以及「低耗電藍牙:Bluetooth Low Energy,BLE」,其比較如下:
同樣地,在「Android」藍牙功能的開發也會依照當前設備所支援的規格不同而有使用不同的「API」,詳細可以參考「Google 官方文件」:
事實上,雖然「傳統藍牙」與「低耗電藍牙」是不同的機制,但同為「藍牙通訊」,其在開發上仍有著相當多類似的地方。
因此,在本文中,筆者會將兩者合併探討,並指出其相異的部份。
藍牙權限的管理
首先,是權限的部份。
由於安全性的考量,「Android」在「API 23」以後導入了權限管理的機制,因此,不論是「傳統藍牙」或是「低耗電藍牙」,只要是在「Android 6.0」以後,都會受到權限機制的影響。
與「藍牙功能」相關的權限,主要有以下三項,其分別為「BLUETOOTH」和「BLUETOOTH_ADMIN」以及「LOCATION」。
如上圖所描述,與藍牙通訊中的「連線」有關,屬於「normal」等級的權限。
如上圖所描述,與藍牙通訊中的「搜尋」、「配對」有關,屬於「normal」等級的權限。
「LOCATION」
根據官方文件所描述,因為藍牙功能可以用來蒐集或暴露位置相關的資訊,因此,若我們要使用藍牙功能,我們需要宣告「LOCATION」。
而根據規定,在「API 28」後,我們必需取得「ACCESS_FINE_LOCATION」的位置權限。
但在其之前的版本,我們可以用「ACCESS_COARSE_LOCATION」權限來取代「ACCESS_FINE_LOCATION」權限。
但不論上述而者,其皆屬於「危險級別」的權限。
備註:「ACCESS_FINE_LOCATION」和「ACCESS_COARSE_LOCATION」皆屬於「LOCATION」的「權限組」。
而權限加入的方式是在「AndroidManifest.xml」中,用「uses-permission」標籤進行宣告,如下:
<manifest ... >
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /><!-- If your app targets Android 9 or lower, you can declare
ACCESS_COARSE_LOCATION instead. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /></manifest>
牽強的解釋
為什麼藍牙功能需要位置權限,上圖為「Google」在官方文件中的解釋。
但許多開發者認為「Google」的理由過於牽強,並不買單,事實上,該問題也曾在「Google」的「IssueTracker」上有過相關的「討論」。
同樣地,該問題也曾在「StackOverFlow」被討論過,可以參考「連結」,如下圖:
或是參考「此篇」,如下:
此外,雖然在官方文件中的描述是,使用「藍牙功能」需要取得「位置權限」。
但根據筆者的實際經驗,在某些「Android」設備中,使用「傳統藍牙」即使沒有取得「定位權限」,亦可以正常運作。
為什麼會這樣呢?
事實上,會有這麼多開發者指責「Google」,是有一定緣由的。
若讀者對這議題有興趣,筆者推薦這篇「A Curious Relationship: Android BLE and Location」,或許在你讀完了以後,你就會對「為什麼僅有『BLE』需要『位置權限』,而又為什麼有那麼多『開發者』指責『Google』?」。
但不論立場為何,筆者認為既然「Google」都已經在官方文件中明確表示使用「藍牙功能」須具備「定位權限」。
因此,筆者還是建議依照官方文件的敘述實作,以避免某些非預期的錯誤。
解套的「Companion Device Pairing」?
事實上,在權限這部份,「Google」在「API 26」以後,推出了「CDP」機制,在該機制中,就不再需要「位置權限」的取得。
其詳細介紹與實作方式可以參考小弟的另外一篇「文章」。
取得藍牙適配器
在處理權限的問題後,接著,就是要取得藍牙適配器。
就如同文件所描述,「BluetoothAdapter」是操作「藍牙功能」所必要的元件。
根據「設置藍牙」的敘述,「BluetoothAdapter」的取得可以藉由一靜態方法「BluetoothAdapter#getDefaultAdapter()」;若該方法返回「null」,則代表該設備不支援藍牙功能,如下:
但是,如果讀者們有特地去閱讀「BluetoothAdapter」的說明文件,就會發現它有這麼一段描述:
是的,它建議開發者們在「JELLY_BEAN_MR1,API 17」以下的版本才去使用「BluetoothAdapter#getDefaultAdapter()」來取得藍牙適配器;若版本高於「JELLY_BEAN_MR1」,則建議用「BluetoothManager#getAdapter()」的方式來取得「BluetoothAdapter」。
雖然這段敘述與「藍牙概覽」的內容並不衝突,但是就現行的專案來說,幾乎不會有專案會向下支援到「API 17」的版本。
下圖是「API 18」以上的支援程度:
下圖是「API 17」以上的支援程度:
是的,其「市占率不到 1%」,因此,筆者實在不懂為什麼「Google」不去修改其官方文件,也許是人力不足吧。
事實上,根據筆者實際經驗,即使 Android 的版本在「JELLY_BEAN_MR1」之上,同樣可以藉由靜態方法「BluetoothAdapter#getDefaultAdapter()」來取得藍牙適配器。
為什麼會有這樣的情況呢?
我們直接從源碼來分析,首先是「BluetoothManager#getAdapter」,其撰寫方式如下:
而我們接著去看「BluetoothManager」的是如何取得適配器,如下:
是不是突然懂了什麼?
是的,事實上,其兩者的差別僅在於對「Context」的管理。
而「getDefaultAdapter()」就如同文件講的,它是去獲取系統的「藍牙適配器」,如下:
源碼如下:
最後,還是那句老話,為了避免預期外的錯誤,筆者仍建議在實作上就遵循著官方文件的敘述。
確認設備是否具有藍牙功能
如上圖,如果「BluetoothAdapter」的回傳值為「null」即目前裝置的硬體設備不支援藍牙通訊的功能。
事實上,我們亦能根據「getDefaultAdapter()」的實作中知道這件事情。
啟用藍牙
在確認設備支援藍牙功能後,接著我們必須去確認藍牙是否被啟用。
但為什麼藍牙功能是可關閉?
因為早期的藍牙相當耗電,這是一件很嚴重的事情,尤其在行動裝置上。
在行動裝置的設計上,能源續航必然是最重要的課題之一,因此考量,藍牙的設計並非常駐,且預設幾乎都是關閉的。
但在藍牙第四代之後,藍牙耗電的情況被大幅改善,加上「IOT」的應用越發廣泛,因此,許多行動裝置的設計逐漸轉為「藍牙常駐」的設計。
當然,除了上述的原因之外,安全性的改善,也是使得「藍牙常駐」逐漸成為主流的原因之一。
但不論藍牙是否常駐,藍牙功能終究是可以被關閉的,因此,檢查藍牙功能是否啟動就是一件必須的事情。
在「Android」上,開發者可以透過「BluetoothAdapter#isEnabled()」來判斷,如下:
其結果等同於狀態「BluetoothAdapter.STATE_ON」,如下:
若目前設備的藍牙功能沒開啟,則有下列兩種方式開啟。
第一種是強制性的,藉由「BluetoothAdapter#enabled()」,但「Google」並不建議其用於一般產品的開發。
而另外一種則是透過帶有「ACTION_REQUEST_ENABLE」參數的「intent」去詢問使用者,實作代碼如下:
if (bluetoothAdapter?.isEnabled == false) {
val enableBtIntent =
Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
}
同時,這也是「Google」建議的操作方式。
搜尋設備
接著,就是「Scan」的部份。
在「Android Bluetooth API」的設計中,「傳統藍牙」與「BLE」搜尋使用的方式並不一樣。
傳統藍牙
首先是「傳統藍牙」的部份。
我們可以使用「BluetoothAdapter#startDiscovery()」以執行「開始搜尋」的動作,如下:
並使用「BluetoothAdapter#cancelDiscovery()」來「取消搜尋」,如下:
另外,在執行「BluetoothAdapter#startDiscovery()」之前,我們通常會先呼叫「BluetoothAdapter#isDiscovering()」以確認現在掃描是否正在執行。
程式碼如下:
此外,在文件中有特別提到,「Discovering」是一件非常消耗資源的事情,因此,我們應該嚴格監聽其狀態,在不需要時應立即關閉,如下:
而且應避免同時間「搜尋設備」又「連線設備」,這樣可能會導致配對失敗的情況發生,如下:
由於藍牙操作幾乎均屬於「異步回調」機制,包含「藍牙連線狀態」、「設備連線狀態」、「發現設備」…等,因此我們必須註冊「Receiver」來接受由「藍牙模組」所回傳的訊息。
同樣的,監聽「藍牙的搜尋狀態」亦是如此。
首先,我們要知道「startDiscovery()」是一個大約 12 秒的行為,而其開始與結束皆會發出廣播,其字串分為別「ACTION_DISCOVERY_STARTED」以及「ACTION_DISCOVERY_FINISHED」。
因此,註冊接收器,如下:
並在「onReceive()」中執行對應操作:
結束時別忘了解除註冊:
同樣地,「藍牙狀態」的監聽亦是如此。
其廣播字串為「ACTION_STATE_CHANGED」,而該「Intent」會攜帶兩種關於狀態的參數,分別為藍牙先前狀態,「EXTRA_PREVIOUS_STATE」以及藍牙目前狀態,「EXTRA_STATE」,如下:
同樣地,「發現設備」亦不例外,當我們的藍牙設備搜尋到其它藍牙裝置時,它會將其資訊以的廣播方式發送。
其「Action」為「ACTION_FOUND」。
並且我們可以透過其攜帶的參數「EXTRA_DEVICE」和「EXTRA_CLASS」分別取得「BluetoothDevice」和「BluetoothClass」。
BLE
在「搜尋設備」的部份,「傳統藍牙」與「BLE」有著很大的不同。
在「API 21」之前,與「傳統藍牙」的操作方式類似,其執行「開始搜尋」所使用的方法是「BluetoothAdapter#startLeScan()」,如下:
而「終止搜尋」的方法是「BluetoothAdapter#stopLeScan()」,如下:
但與「傳統藍牙」略為不同的是,呼叫「startDiscovery()」時,並不需要傳入任何「Callback」作為參數,但是在呼叫「startLeScan()」時,則被要求傳入「BluetoothAdapter.LeScanCallback」。
事實上,BLE 的設備一樣可以藉由「廣播接收」的機制來取得被搜尋到的設備,其「Action」同樣是「ACTION_FOUND」;但筆者認為「Callback」的方式更為直覺。
上述的部份僅適用於「API 21」之前,多數現行專案都已經不再支援,因此筆者就不附上此部份的實作範例。
事實上,根據筆者的實際經驗,在多數的裝置上,上述的「接口」在「API 21」以後的版本依然是有效的,但還是那句老話,為了避免非預期的錯誤,我們盡量遵循官方文件所建議的規格實作。
備註:根據筆者的實務經驗,「Android」的開案版本幾乎都會設定在「API 23」以上,主要是該版本涉及權限的調整,除非業主有特別的要求。
在「API 21」之後,「SDK」加入了一個新類別「BluetoothLeScanner」,就此,關於「BLE Scan」的相關操作皆轉移至此。
首先是「BluetoothLeScanner」取得的方式,如下:
其是通過「BluetoothAdapter」的類別方法「getBluetoothLeScanner()」來取得:
在取得「BluetoothLeScanner」,若我們要開始執行「BLE」的搜尋,則需要呼叫「startScan()」,如下:
該方法需要「ScanCallback」為必要參數。
程式碼如下:
順帶一提,若在執行掃描的期間,應用程式並不會一直處於運行的狀態,則建議使用「PendingIntent」的掃描方式,如下:
上述的方式同樣可以藉由「廣播接收」的機制來獲得被發現的藍牙設備,註冊字串同樣是「ACTION_FOUND」。
另外還有一點需要特別注意,「BLE」與「傳統藍牙」不同的是,其搜尋的時間並不是一段會自動結束的「期間」,其可以參考「Android: How to get Bluetooth scanning status?」,如下:
因此,在實作上,必須自行控制其「執行期間」。
在「Bluetooth Low Energy Overview」的範例代碼中,其就在「Handler」設置了「執行間期」,如下:
最後,有一點要注意,根據筆者經驗,若使用「BLE」掃描功能時,不僅是取得「定位權限」而已,而是務必確保「啟動定位功能」,否則會無法掃描到任何裝置。
Manufacturer ID
這是「BLE」僅有的特性,它允許藍牙在發送廣播時加入製造商辨識碼,該辨識碼具有「唯一性」,我們可以在藍牙協會的網站上找到「相關資訊」。
使用的方法為「ScanRecord#getManufacturerSpecificData()」,如下:
它可以應用在「廣播辨識」,開發者可以根據廣播的「攜帶資訊」,就可以在廣播時辨識「特定產品」,並可以執行特定的操作,例如自動配對等。
總結
本文主要是探討藍牙的權限管理與掃描機制,而關於藍牙的連線功能與通訊機制,未來有機會我們在另外討論。
事實上,藍牙最大的問題還是來自於底層模組與晶片的紊亂。
即使有「藍牙協會」在制定標準,即使有「Google」規定「Android」的接口規格,但仍舊免不了其差異。
筆者曾經向對「韌體」相當熟悉的資深前輩確認,他告訴我「BLE」與「傳統藍牙」是不同的東西,不論是晶片規格、協定…等。
而在「Google」的官方文件也有提過,如下:
就官方文件的描述,若要掃描「傳統藍牙」的設備,我們要使用的方法應該是「BluetoothAdapter#startDiscovery()」。
而要掃描「BLE」的設備,則是要使用「BluetoothAdapter#startLeScan()」或是「BluetoothLeScanner#startScan()」。
但根據筆者的實際經驗,在部份設備上使用「startDiscovery()」時,是可以搜尋到「BLE」的設備,甚至並不需要開啟「定位功能」。
事實上,這樣的情況也曾有其它開發者碰到過,如下:
其討論串「連結」。
其實「Android」的適配本來就是「Android」開發者的一個課題,不同的版本,不同廠牌的手機、甚至是不同種類的設備;只是在藍牙這種功能上,其問題又更明顯。
若各位遇到同樣的問題,筆者建議,在盡量遵循文件的前提下,與主管或客戶進行溝通;若逼不得已需要以「Workaround」的方式處理,請一定要把文件寫清楚,真的麻煩各位了。
完整程式碼連結:My GitHub