這篇整理電腦經由 Wi-Fi 用 adb 連線到 Android 手機之後,遠端排查用到的指令:在多台裝置中指定要操作的那一台、擷取螢幕畫面與錄影並確認傳回的檔案完整、查出畫面最上層是哪個 App 的哪個 Activity、用 logcat 篩出某個元件啟動前後的紀錄、查詢 App 權限與使用者限制這類狀態,以及放寬安裝管控的修改型指令。Wi-Fi 配對與連線的步驟在Android 無線調試連接指南。

指定裝置

adb 的一行指令分成三段:adb 自己的全域選項、子命令(如 shell)自己的選項、以及子命令之後要送去遠端執行的字串。選擇裝置的 -s / -t 是全域選項,要寫在子命令之前。

1adb devices -l                          # -l 多印 product / model / transport_id
2adb -s <serial> shell <command>
3adb -t <transport_id> shell <command>   # transport_id 較短、同一次連線內有效

寫成 adb shell -s <serial> <command> 時,-s 被劃進 shell 子命令的選項,而 shell 的選項解析器不認得它,回 adb shell: illegal option -- s,指令沒有被送到遠端執行。沒有指定 transport 而多台在線時,adb 在解析目標裝置那一步就失敗、回 adb: more than one device/emulator,shell 的選項解析沒有被執行。同一個寫錯的指令因此有兩種回報,指向選項位置的那一則只在單機在線時看得到。

同一台無線機器可能同時以 IP 位址與 mDNS 名稱兩個 transport 在線,adb devices 因此列出兩列而背後是同一台。adb devices -l 印的 product / model 欄位在同一批機器上完全相同,靠它們分辨不了;硬體序號可以分辨。getprop 讀裝置的系統屬性,引數是屬性名稱,ro.serialno 存的是硬體序號。逐個 transport 問一次,回同一個值的是同一台。

1for serial in $(adb devices | awk 'NR>1 && $2=="device" {print $1}'); do
2  model=$(adb -s "$serial" shell getprop ro.product.model | tr -d '\r')
3  brand=$(adb -s "$serial" shell getprop ro.product.brand | tr -d '\r')
4  sdk=$(adb -s "$serial" shell getprop ro.build.version.sdk | tr -d '\r')
5  hw=$(adb -s "$serial" shell getprop ro.serialno | tr -d '\r')
6  echo "$serial | $brand $model (API $sdk) | $hw"
7done

迴圈的成員取自 adb devices 的輸出:awk 的 NR>1 跳過 List of devices attached 這行標頭,$2=="device" 只留下狀態欄是 device 的列(offline 與 unauthorized 的機器接不上、要排掉),{print $1} 印出第一欄的 transport 名稱。

腳本裡每一行 getprop 的結尾都接了 tr -d '\r',作用是去掉輸出裡的歸位字元(\r)。adb 送輸出回來時若配置了 pty(pseudo-terminal,虛擬終端機),而作業系統核心的 pty 行規則會把每個 0x0A(換行)補成 0x0D 0x0A(歸位加換行)。補進來的 0x0D 肉眼看不見,但它會讓 shell 的字串比對失敗——"$model" = "<型號字串>" 這種比較永遠回假,因為 $model 的值尾端多了一個 \r。沒有配置 pty 時 tr -d '\r' 是空操作,加上去沒有副作用。

遠端擷圖

1# 不寫路徑:PNG 的位元組送到標準輸出,由本機的 > 存檔,裝置端不留中繼檔
2adb -s <serial> exec-out screencap -p > screen.png
3
4# 寫路徑:PNG 存成裝置上的那個檔,標準輸出是空的,檔案要另外 pull 回來
5adb -s <serial> shell screencap -p /sdcard/screen.png

screencap 在裝置上抓當前畫面,-p 指定編碼成 PNG。它另外接受一個選用的引數——輸出的檔案路徑——上面兩條的差別只在寫不寫這個引數,而它決定畫面往哪裡去。

兩條各自的完整流程分開講:第一種的位元組要經過 adb 這條通道回到本機,本節接下來處理那條通道上的兩個問題(行尾轉換與 stderr 混入);第二種在〈舊環境的兩段式做法〉,它多兩條指令(取回與清除),而因為沒有位元組經過通道,用 shell 送就夠了。

位元組從裝置送回本機的過程中會經過一次可能的行尾轉換。走 exec-out 時 adb 不配置 pty,位元組原樣送回;走 shell 且配置了 pty 時,pty 行規則會把 0x0A 換成 0x0D 0x0A。PNG 裡的 0x0A 是資料,被換過的每一處都讓檔案多一個位元組,解碼失敗。

adb shell 配不配置 pty 取決於 adb 版本、裝置端有沒有 shell protocol v2(adb features 裡有沒有 shell_v2)、以及有沒有帶命令參數——帶命令的不配置,不帶命令的互動模式才配。這條規則在裝置有 shell_v2 且沒帶 -t / -tt 時成立,沒有 v2 的舊組合不適用。用 exec-out 取二進位輸出,行尾轉換與否不再取決於這組版本組合。

可以用一段已知的位元組確認手上這條通道有沒有配 pty:

1adb -s <serial> exec-out "printf 'a\nb\n'" | xxd
2# 610a 620a        — 沒有轉換
3
4adb -s <serial> shell -tt "printf 'a\nb\n'" < /dev/null | xxd
5# 610d 0a62 0d0a   — 轉換了

exec-out 有一個代價:它把遠端的 stderr 併進 stdout。遠端吐一行警告,它就排在 PNG 前面進了檔案。file 與本機 stderr 分不出這種壞檔跟行尾被改寫的壞檔——兩種都是 file 回 data、stderr 零行;分得出的是檔案開頭的位元組(xxd -l 16)。

1adb -s <serial> exec-out "printf 'OUT\n'; printf 'ERR\n' >&2" | xxd
2# 4f55 540a 4552 520a        — stderr 混進來了
3
4adb -s <serial> shell "printf 'OUT\n'; printf 'ERR\n' >&2" | xxd
5# 4f55 540a                  — shell(有 v2 時)分開處理

驗證檔案

1file screen.png
2# 完好:PNG image data, <寬> x <高>, 8-bit/color RGBA, non-interlaced
3# 開頭被塞了別的內容(行尾改寫或 stderr 混入):data
4# 零位元組(顯示器編號錯):empty

data 有兩種來歷,分辨看檔案開頭:xxd -l 16 screen.png。完好 PNG 以 8950 4e47 0d0a 1a0a 起頭;行尾被改寫的簽章變成 8950 4e47 0d0d 0a1a;stderr 混入的開頭是讀得懂的文字。

舊環境的兩段式做法

exec-out 不可用時,讓裝置端先寫檔再 pull 回來。三條指令的引數各是什麼:

  • screencap -p <裝置上的路徑>:寫了路徑,screencap 把 PNG 存成裝置上的檔案
  • pull <裝置上的路徑> <本機的路徑>:第一個引數是來源、在裝置上,第二個是目的地、在本機,順序固定。pull 走檔案傳輸通道,不做行尾處理、也不併 stderr
  • rm <裝置上的路徑>:刪掉中繼檔,裝置上不留排查產生的檔案

路徑寫在 /sdcard 底下,是因為 pull 與 shell 讀得到的範圍不一樣:/sdcard 兩者都讀得到,系統分割區 shell 讀得到而 pull 可能回 Permission denied。/sdcard 是 /storage/emulated/0 的符號連結,adb -s <serial> shell ls -l /sdcard 印得出這個對應;後面〈狀態查詢〉查的 App 資料目錄在同一個掛載點底下。

1adb -s <serial> shell screencap -p /sdcard/screen.png
2adb -s <serial> pull /sdcard/screen.png ./device-screen.png
3adb -s <serial> shell rm /sdcard/screen.png

錄影

1adb -s <serial> shell screenrecord --time-limit <秒數> /sdcard/demo.mp4
2adb -s <serial> pull /sdcard/demo.mp4 ./

screenrecord 的引數是錄影檔在裝置上的輸出路徑,所以錄完要 pull 回本機;--time-limit 接的是秒數。pull 的第二個引數寫 ./ 時,本機端沿用來源的檔名。時間上限由 screenrecord 自己定,adb -s <serial> shell screenrecord --help 會印出來。

多螢幕機器的顯示器選擇

screencap 預設抓主顯示器。POS 機的客顯是對話框可能出現的地方之一,要加 -d 指定顯示器。

編號要取自 SurfaceFlinger,不是 dumpsys window 的 mDisplayId。 兩套編號不相容,把 mDisplayId 餵給 screencap -d 的結果是成功退出、stderr 零行、檔案零位元組。screencap --help 自己就寫著要去 SurfaceFlinger 查。

1adb -s <serial> shell dumpsys display | grep mViewports    # 同一行印出兩套編號的對應
2
3# 不加 -d:抓主顯示器
4adb -s <serial> exec-out screencap -p > screen.png
5
6# 加 -d:抓指定的顯示器
7adb -s <serial> exec-out screencap -p -d <SurfaceFlinger 的編號> > screen.png

dumpsys display 的 mViewports 行裡 displayId= 是 dumpsys window 那一套,uniqueId='local:<數字>' 裡的數字是 screencap -d 要的那一套。

前景元件查詢

screencap 的擷圖顯示畫面上寫了什麼;畫面由哪個元件繪製要用 dumpsys window 查:

1adb -s <serial> shell dumpsys window | grep -iE "mCurrentFocus|mFocusedApp"

輸出帶套件名與 Activity 名。多螢幕的機器上,dumpsys window 替每個顯示器各印一組 mCurrentFocus 與 mFocusedApp,沒有焦點的顯示器這兩個欄位的值都是 null。讀輸出的人跳過值都是 null 的那幾組,值不是 null 的那一組就屬於有焦點的顯示器。

一個視窗可以畫在有焦點的視窗之上而不取得焦點——APPLICATION_OVERLAY 且 NOT_FOCUSABLE 的視窗就是這樣,mCurrentFocus 的值不變。任何持有 SYSTEM_ALERT_WINDOW 的第三方套件都畫得出來。排除 overlay 的方法:

1adb -s <serial> shell appops query-op SYSTEM_ALERT_WINDOW allow     # 誰有這個權限
2adb -s <serial> shell dumpsys window windows \
3  | grep -E "Window #|package=|ty=|mViewVisibility="                # 當下的視窗堆疊

appops query-op 的兩個引數是 op 名稱與 mode,它列出在那個 op 上被設成該 mode 的套件。所以第一條列的是能力不是行為,有權限的套件不一定當下有視窗在畫面上。第二條的 windows 是傳給 window 服務的參數,要它印視窗清單那一段;輸出按 z-order 由上而下列出視窗,要同時看三個條件:排在目標之上、ty=APPLICATION_OVERLAY、且 mViewVisibility=0x0。第三個條件用來排除隱形視窗:0x4 是存在但不可見,系統自己就常駐幾個這樣的視窗(拖放目標、螢幕裝飾)。三個條件同時成立才代表有第三方視窗正在疊圖。從截圖分不出 overlay 與被改寫的元件——兩種在畫面上呈現相同的文字;分得開兩者的是 z-order 那份輸出。

logcat

logcat 讀的是裝置上的環狀緩衝區,寫滿之後從頭覆蓋。時間窗由清空與倒出界定,中間那一段是重現問題時裝置寫進去的。

macOS 上常見的替代做法是用 timeout 控制收集時間,但 timeout 屬於 GNU coreutils,macOS 預設沒有(裝了 coreutils 之後叫 gtimeout)。timeout 300 adb logcat > install.log 在 macOS 上留下的是 0 bytes 的 install.log——command not found: timeout 走 stderr、沒進重導檔。清空與倒出已經界定了時間窗,收集時間由操作本身決定,本機的計時程序沒有需要做的事。

1adb -s <serial> logcat -G 16M                          # 放大緩衝區
2adb -s <serial> logcat -c                              # 清空,時間窗起點
3# (在機器上重現問題)
4adb -s <serial> logcat -d -v threadtime > install.log    # 倒出,時間窗終點
5adb -s <serial> logcat -g                              # 確認各緩衝區大小
參數用途
-G 16M放大緩衝區。POS 機、車機這類常駐服務多的機器,緩衝區在重現過程中就可能把最早的行覆蓋掉——關鍵行往往正是操作之前的那幾行。16M 是起點,對著 -g 的現況調
-c清空
-v threadtime每行帶 PID / TID 與精確時間戳
-d一次倒出後結束,不停在前景等
-g每個緩衝區各印一行,設完跑一次看 -G 涵蓋了哪幾個

PID 與 tag

-v threadtime 的欄位順序是日期、時間、PID、TID、層級、tag。PID 直接指出發出這一行的 process;tag 由各元件自行填寫、跨元件會重複,只憑 tag 把 log 行對應到 process 是推測。

篩選

時間排在第二欄,零填補定寬(HH:MM:SS.mmm),可以直接用字串比較篩。兩側都要寫——logcat 會插入 --------- beginning of main 這類分隔行,第二欄是 beginning,字串序大於任何時間值,只寫下界會把它放進來。

1grep -vE "<佔多數的服務 tag>" install.log | tail -<行數>
2awk '$2 >= "16:26:00.000" && $2 <= "16:27:00.000"' install.log \
3  | grep -viE "adbd|<該機常駐服務的 tag>"

找出佔多數的 tag:

1awk '{print $6}' install.log | sort | uniq -c | sort -rn | head

logcat 把 tag 欄補寬到八個字元。短於八個字元的 tag 後面補了空白,awk 用空白切欄時那個冒號因此被切成獨立的一欄,所以長 tag 在第六欄帶冒號(WifiVendorHal:)、短 tag 不帶(adbd)。統計結果貼回 awk 做精確比對前,先確認有沒有尾冒號。

找特定元件的啟動紀錄

安裝器有沒有被啟動,看 ActivityTaskManager 的視窗切換紀錄:

1grep -E 'ActivityTaskManager.*(START|Displayed)' install.log | grep -i <安裝器套件名>

這條 grep 的輸出裡有 cmp=<套件>/.InstallStart 這一行,代表 App 已經送出安裝意圖;輸出是空的,代表 App 沒有送出安裝意圖。

狀態查詢

dumpsys <服務> 把問題轉給那個服務自己的 dump 實作,dumpsys window 和 dumpsys user 的回答由不同的元件產生,格式各自決定。服務不存在時回一行 Can't find service: <名字> 並成功退出,grep 的結果是零行——先用 dumpsys -l | grep <服務> 確認服務在不在。

1adb -s <serial> shell pm list packages | grep -i <keyword>
2adb -s <serial> shell appops get <package> REQUEST_INSTALL_PACKAGES
3adb -s <serial> shell dumpsys device_policy | grep -iE "owner|admin|restriction" -A<行數>
4adb -s <serial> shell dumpsys user | grep -i restriction -A<行數>
5adb -s <serial> shell settings list secure | grep -i <key>
6adb -s <serial> shell settings get secure <key>
7adb -s <serial> shell pm resolve-activity -a android.settings.MANAGE_UNKNOWN_APP_SOURCES
8adb -s <serial> shell dumpsys package <package> | grep -iE "versionCode|versionName|codePath|^Packages:|Hidden system packages:"
9adb -s <serial> shell ls -l /storage/emulated/0/Android/data/<package>/

appops

appops get 的兩個引數是套件名與 op 名稱,回報那個套件在那個 op 上目前的 mode。mode 是一組固定值(allow / ignore / deny / default,位置類另有 foreground)。ignore 與 deny 都讓呼叫拿不到值,差別在 deny 回報失敗、ignore 靜默回空。套件在那個 op 上從沒設定過 mode 時,appops get 印出這兩行:

1No operations.
2Default mode: default

dumpsys user

要讀的是目標使用者底下的 Effective restrictions。grep -i restriction 的輸出還包括 Guest restrictions(guest 使用者型別的預設清單)、mDefaultRestrictions、Device policy global restrictions,它們都不是當前使用者的有效值。判別看它落在哪個標題底下:UserInfo 區塊裡的屬於那個使用者,Device properties 或使用者型別定義裡的是型別預設。Guest restrictions 裡面本來就列著 no_install_unknown_sources——在沒有設定任何使用者限制的機器上,grep 的輸出照樣包含這一行,它與當前使用者的限制無關。

dumpsys device_policy

device owner 管整台機器,profile owner 只管工作資料夾,兩者都能下安裝限制。排除管控層要兩個都是空的。

settings

settings 的引數依序是動作(list / get / put)、namespace、鍵名。namespace 分成 global / secure / system 三個,一個鍵通常只在其中一個裡面有值。跨版本搬過家的鍵會在舊 namespace 留下值為 null 的殘餘,null 因此蓋住三種情形:查錯 namespace、這台機器沒有這個設定、以及殘餘。settings list <ns> | grep <key> 命中不等於值在這裡——三個 namespace 都 get 過一輪,確認只有一個回非 null,才能確定值在哪。

dumpsys package

更新過的系統 App 有兩組值:出廠版在 /system/app、更新版在 /data/app。分辨靠 dumpsys 印的段標——Packages: 底下是目前生效的那一組、Hidden system packages: 底下是被蓋掉的出廠版。grep 要把段標一起收進來。

pm resolve-activity

-a 後面接的是 intent action 的名稱,pm resolve-activity 回答系統會把這個 action 交給哪個元件處理。輸出的 ActivityInfo 區塊帶 name= 與 packageName=,指出 action 落在誰身上。下方巢狀的 ApplicationInfo 另有一組同名欄位,那是套件的 Application 類別,直接 grep name= 會拿到兩個不同的值。

App 的外部儲存資料目錄

ls -l 後面那條路徑是套件在外部儲存上的私有資料目錄。App 把自己下載的檔案放在這裡時,這一條確認那個檔案在不在。/storage/emulated/0 是當前使用者的外部儲存掛載點,與前面擷圖用的 /sdcard 指向同一處。

放寬安裝管控(修改型指令)

REQUEST_INSTALL_PACKAGES 是「這個套件可以觸發安裝其他套件」的授權。正常由使用者在設定頁逐一開啟,用指令設成 allow 等於跳過確認,改變的是安全設定,只適用自己擁有、可以重置的開發測試機。

1# 放寬(僅限自有開發測試機)
2adb -s <serial> shell appops set <package> REQUEST_INSTALL_PACKAGES allow
3
4# 還原
5adb -s <serial> shell appops set <package> REQUEST_INSTALL_PACKAGES default

還原寫 default 而不是 deny:default 把判斷交還系統預設規則,deny 是主動拒絕,後續行為不同。驗證完還原,記錄改過哪一台——沒還原的機器在後續測試裡不再代表出廠狀態。

相關指引

同一批 POS 排查的其餘指引: