Skip to content

來讓 MangaMeeya 支援 Unicode 檔案名稱吧!

只要有在 Windows 下載漫畫的人都應該知道什麼是 MangaMeeya,不過 MangaMeeya 一直有一個大問題就是不支援 Unicode,只要開啟的檔案名稱含特殊符號或目前系統語言不支援的文字時就會顯示路徑無效,就如下面的截圖:

這個問題一直持續了很多年,曾經有人提供過修改執行檔指向系統 DLL 的方法,但似乎對目前最新版本 2.4 好像無效。就因為這樣,我用了些時間製作了供 MangaMeeya 使用的 Susie 插件以解決這個問題,毋需修改任何檔案。

對比以前我一直用 Loader 透過 CBT Hook 注入 DLL 來修改 IAT (延遲匯入位址表) 的方法,這次直接透過 MangaMeeya 的 Susie 插件機制來修改 IAT,免卻了要使用 Loader 的問題。這個插件的原理是攔截與開啟檔案有關的 WINAPI,當看到輸入的檔案名稱有問號時即代表該檔案無法以目前字碼頁表示,這時候插件就會用這個檔案名稱作為搜尋條件用 Wide Char 版本的 FindFirstFileW API 搜尋第一個符合的檔案,再用攔截原 API 的 Wide Char 版本來處理該檔案就可以讓 MangaMeeya 順利開啟。

要使用這個插件需要修改一個設定才行,首先下載 Unicode.spi 後請將檔案複製到 MangaMeeya 裡的 SusiePlugin 目錄,然後開啟 MangaMeeya 並選擇工具(ツール) –> 環境設定,然後選擇載入器設定(ローダ設定),在 Susie 插件設定部份選擇 Susie 插件優先 (如下圖):

你也可以按下插件設定按鈕來確認插件已被程式識別:

設定完成後按 OK,再試一次開啟剛才無法開啟的檔案,這時候應該就能成功開啟了!

經測試可以在開啟對話框、檔案總管右鍵開啟以及拖放到已開啟程式來開啟檔案,不過目前測試出有一個限制,不知道是 MangaMeeya 的設計還是 bug,經 MRU (最近使用檔案) 開啟檔案時,Susie 插件會在開啟檔案後才載入,換而言之,當啟動程式後隨即從 MRU 開啟 Unicode 名稱檔案時需要開啟2次才行,這個小問題以目前的做法應該無法解決。

插件原始碼及編譯好的插件可以在 https://github.com/starkwong/MangaMeeya_Unicode 找到。


Fixing NanoHTTPD 400 Error - Another Way

Recently I need a HTTP proxy server in an Android app to let the WebView inside the app to use server side API calls that does not set CORS header, so I chose to use NanoHTTPD which is the easiest way for Android. For HTTP GET requests all requests are fine, however for HTTP POST requests some weird behaviors from NanoHTTPD appears:

  • The WebView received HTTP 400 response but the requests do not reach the serve() callback
  • The remote server complained incomplete parameters

After some search it is confirmed to be a bug in NanoHTTPD and suggestion to fix the issue is also provided (StackOverflow post). However the most troublesome for the fix is that it needs to be fixed from the NanoHTTPD source code directly, which is not ideal that I included the dependency directly from Maven Central.

So instead of cloning a copy of NanoHTTPD source code and fix it there, I found a way without the need to do it. From HTTPSession.java (Github source) starting from line 413, we can see that the HTTPSession will receive a Response object from the handle() method which essentially just in turn calls the serve() method which will be overridden from our implementation. After that it will configure the response object and call the send() method to write the response to the OutputStream. After that only some cleanup is performed without touching the original input stream that it forgot to close.

I tried first to close the input stream in serve() method before returning, but it doesn’t work. So I tried to delay the input stream closing until after sending the response, but how? Did you remember the HTTPSession will call the send() function from the response object? By subclassing the Response class and overloading the send() method, we can accomplish what we want. My sample implementation (in Kotlin):

class Response2(val serveInputStream: InputStream, status:IStatus, mimeType:String?, data: InputStream?, totalBytes: Long) : Response(status, mimeType, data, totalBytes) {
   override fun send(outputStream: OutputStream?) {
       super.send(outputStream)
       serveInputStream.close()
   }
}

fun newFixedLengthResponse2(serveInputStream: InputStream, status: IStatus, mimeType: String?, data: InputStream?, totalBytes: Long): Response {
   return Response2(serveInputStream, status, mimeType, data, totalBytes)
}

How to use? For any response that you created with newFixedLengthResponse(), use the new method newFixedLengthResponse2() instead, which needs one more input parameter for the input stream passed from serve(), e.g.

context.assets.open(file).let {
   return newFixedLengthResponse2(this.inputStream, Response.Status.OK, URLConnection.guessContentTypeFromName(file), it, it.available().toLong())
}

By adding these changes, the input stream from the session can be properly closed and avoiding any issues for POST request.


公用服務:Google Play Developer Announcements RSS

蘋果的 Apple Developer Latest News 有官方的 RSS 文件,可以令我這一類會經 Feedly 或其他 RSS 閱讀器觀看新聞的人可以知道關於開發條件的重要更新 (例如什麼時候要強制使用新的 SDK),然而 Google 卻一直沒有為 Google Play Developer Announcements 提供類似服務。我在網上搜尋過似乎沒人有弄類似的東西,於是我就自己動手弄了一個以 PHP + XSLT 製作的轉換器。轉換器每天會更新 RSS 4 次,更新時會寫入靜態 RSS 檔案供外部存取。

RSS 資料來源可使用以下地址︰ https://www.studiokuma.com/rss/g/gpa.rss

iOS 16.6 的 AVPlayer 問題

我們最近收到客戶報告說我們的播放程式不正常,當我們用升級到 iOS 16.6 的 iPad 進行測試時的確發現兩個問題:

  1. 有些時候畫面會凍結,但使用者仍可操作界面
  2. 有些時候裝置會異常發熱,而且點擊屏幕反應極慢或沒反應,再過一段時間程式自動被系統殺掉

經過一段時間研究後,我們發現問題是出現在 AVPlayer (mediaserverd) 中切換畫質的部份,當符合下列條件時有超過一半可能會觸發:

  1. 裝置為 iPad (實測發生問題的裝置至少有 iPad Mini 及 iPad 9.7 吋)
  2. 系統已升級到 iPad 16.6
  3. 播放程式有在播放開始時/前限制最大頻寬
  4. 播放途中有因超過最大頻寬而觸發降低播放畫質

我們最初發現的問題是源自串流超過定義頻寬,例如是程式中定義最大頻寬為 6Mbps,而串流中定義頻寬為 5.3Mbps,理論上實際頻寬峰值不應該超過定義值,然而在我們實際所得,該串流峰值每隔一段時間就會超過 6Mbps,而當每次超過定義值的時候,mediaserverd 會將該定義值更新為新的峰值。當新的峰值超過我們所限制的最大頻寬時,mediaserverd 就會將該畫質排除而選擇次一級的畫質。然而在 iOS16.6 下,mediaserverd 有可能會不斷在兩個畫質之間切換 (每秒可達 7 次),由於每次畫質切換會向串流伺服器發出額外的請求,導致超過串流伺服器所設定的同時連接數閥值而令伺服器傳回HTTP 429 (請求過多) 錯誤,而 mediaserverd 由於重覆請求失敗而令所有畫質都被排除,最後導致播放停止,也就是第一個問題的成因。不過即使我們將串流伺服器的同時連接數加大,雖然可以避免播放停止,但由於不同切換畫質耗用處理能力,在問題出現約5分鐘就會導致程式無法回應使用者操作。相關的記錄可以在這裡找到。

這個問題似乎還有另一個方法會觸發而並非因為超過頻寬。在我們的測試串流中,在 5.3Mbps 畫質下面是一條約 3.5Mbps 的畫質,當開始播放前將最大頻寬設成 4Mbps 時,問題 100% 會觸發。

這個問題還有一個連帶的影響,就是當播放開始後無法在中段改變最大頻寬 (例如我們在程式中可以讓使用者因為網路環境降低畫質以改善流暢度)。在 iOS 16.6 中改變最大頻寬並不能觸發 mediaserverd 將播放出中而高於最大頻寬的畫質排除。

對於這個問題,我們目前只能將最大頻寬由 6Mbps 改為不可能在中途觸發的 10Mbps,直至串流伺服器能解決峰值過大的問題為止。至於 iOS 17 下是否有這個問題我們暫時不清楚,因為手上沒有升級到 iOS 17 的 iPad。但從 iOS 17 的 iPhone 測試所得,雖然未發現上述的問題,但仍發現重覆觸發(但並非不斷觸發)切換相同畫質,而且仍然是無法在中途變更畫質,所以我懷疑這個問題在 iOS 17 也有出現。

如何在 Android Studio 使用 Local Repository (本端資源庫)

我們最近用 Kotlin 開發了一個程式庫,不過當把程式庫提供予其他團隊整合時發現我們用的 Kotlin 版本太新 (1.7.10),導致他們也要升級 Kotlin 版本,而升級 Kotlin 版本則需要同時升級 Gradle 及 Android Gradle Plugin 版本,然而升級 AGP 後則因為他們的程式含本端 aar 而導致編譯失敗。官方的解決方法是每個本端 aar 都開一個 module,這對於只用一兩個本端 aar 的開發者是沒什麼問題,但比較大的專案往往會從第三方取得 aar,結果會有超過 10 個 aar,這樣每個 aar 都開一個 module 的話看起來會比較亂。另一個方法就是將 aar 放在 maven repository,不過要為這個原因而架一個網站伺服器來放 aar 又是有點過份。不過其實 Gradle 是支援本端 maven repository 的,只需要將本來放在網站伺服器上的檔案放在指定的目錄下就可以,而且這個目錄可以相對於專案的目錄,其他開發者從 VCS checkout 專案後不需要進行任何修改即可進行編譯。

目錄結構

首先說一下目錄結構,將 maven 的檔案放入與專案的 app 目錄相同層級就可以,以下是一個例子:

AndroidProject
|-app
|-build
|-maven
| |-com
|   |-amulyakhare
|     |-textdrawable
|       |-1.0.1
|         |-textdrawable-1.0.1.aar
|         |-textdrawable-1.0.1.pom
|-build.gradle

Maven Artifact 的格式為 ‘groupId:artifactId:version’,在以上例子就是 ‘com.amulyakhare:textdrawable:1.0.1’,也就是在 build.gradle 裡 implementation / compileOnly 裡的那個。

POM 檔案

至於 pom 檔案可以用簡單幾句描述這個 Artifact 的內容,另可以宣告這個 Artifact 的相依性,令 Gradle 會自動引用這個 Artifact 所需的其他 Artifact。以下是 pom 檔案的例子:

<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.amulyakhare</groupId>
   <artifactId>textdrawable</artifactId>
   <version>1.0.1</version>
   <packaging>aar</packaging>
   <dependencies>
     <dependency>
       <groupId>androidx.fragment</groupId>
        <artifactId>fragment</artifactId>
        <version>1.3.6</version>
        <scope>compile</scope>
     </dependency>
   </dependencies>
</project>

這裡的 groupId、artifactId 和 version 必須與目錄結構相同,而 dependencies 裡面則為選填的內容,如無相依性則保留空白內容的 dependencies 即可。如以上的範例所示,每個相依性可以是在不同的 maven repository。

引用本端 maven respository

完成放置所有 aar 並編寫所需的 pom 後,最後的步驟就是在 build.gradle 引用這個本端資源庫。這個引用可以寫在全域或 app 的 build.gradle,這裡的示範是在 app 的 build.gradle (在 dependencies 上面):

repositories {
     maven {
         url "${rootProject.projectDir}/maven"
         content {
             includeGroup "com.amulyakhare"
         }
     }
}

利用 ${rootProject.projectDir} 變數就可以引用相對路徑的 maven repository。content 這個區塊可加可不加,加的話可以確保包含群組以外的 Artifact 不會嘗試在這個 maven repository 中下載。

引用後,要使用這個 Artifact 跟一般的使用方法完全相同:

implementation 'com.amulyakhare:textdrawable:1.0.1'

這樣就可以確保與以後的 Gradle / AGP 版本相容。

去網路化 - okhttp 4.3

某程式的新版本改用了 okhttp 4.3 或以上的版本,沿用的去網路化方式不再適用。

在舊版本的 okhttp 中,Dns$1.lookup() 裡可以直接跳到 :cond_0 以拋出 UnknownHostException,然而在新版本的 okhttp,Dns$1 被 Dns$Companion$DnsSystem 所取代,而 lookup() 方法裡並沒有檢查參數是否空值 (即原來 if-eqz p1, :cond_0),改為依賴 InetAddress.getAllByName() 拋出 NullPointerException,然後再改拋 UnknownHostException。

catch_0 就是改拋 UnknownHostException 的位置,但由於它會將原來捕捉到的例外存到 v0 並在後面插入到例外原因,所以我們不能直接用 goto 跳到那個位置。理論上我們可以把 move-exception v0 和 .line 3 後面 initCause 那句刪掉就應該可以用 goto,但有沒有更簡單的方法呢?

答案是有的!既然我們知道這個 lookup() 方法會捕捉 NullPointerException,那麼我們讓 InetAddress.getAllByName() 拋出 NullPointerException 就行。至於方法當然就是直接讓傳入參數變成 null!我們只需要在呼叫 InetAddress.getAllByName() 前 (在 .line 1 與 :try_start_0 之間) 加 “const/4 p1, 0x0” 就可以讓 lookup() 的第一個參數變成 null (Dalvik 中物件是以地址方式存放在變數中,所以將地址改成 0 即為 null),然後在呼叫 InetAddress.getAllByName() 的時候就會拋出 NullPointerException,至此對 okhttp 4.3 的去網路化完成。

請小心為你的 Android Layout 改名,否則可能會出現奇怪的問題

在我們所開發的程式中,其中一個是以 aar 方式封裝播放器的介面,再加上第三方的 SDK (同樣以 aar 方式) 亦有播放介面,然後一併交由另一個團隊進行整合,一直都沒有問題。然而就在一個比較大的播放器介面更新時,我們改動過一些 Layout XML 並將重用的部份分離出一些新的 Layout XML,結果出現一個非常奇怪的問題:

  • 我們有一個自己寫的程式可以測試介面 aar 以及第三方播放 SDK 的功能,用那個程式測試時沒有問題
  • 對方團隊那邊執行我們的測試程式時也沒有發現問題
  • 對方團隊在將所有 aar 整合後,發現第三方播放器 SDK 開啟時發生 NullPointerException

初步分析

當時我們覺得很奇怪,在 Android Studio 的反編譯功能協助下,我們發現 NullPointerException 的原因是前面的 findViewById() 傳回 Null 值,但既然該播放器 SDK 單獨使用並無問題,而 findViewById() 指向的 ID 又確實存在 (否則應該無法編譯),所以肯定可以排除是第三方播放器 SDK 的問題。那樣的話我們只能在該次介面更新的範圍進行測試,最後發現把重用的 Layout XML 刪除就沒有問題,然後就一直到現在都再沒問題。

進階測試

我一直對這個解決方法都有疑問,直至最近想起,Android 上無論是 Java 還是 Kotlin 都有 package 的概念 (也就是只要大家的 package 不同,同樣名稱的源碼檔案其實是不同的類,只要匯入時使用正確的 package 名就能使用正確的類),至於 XML 呢?於是我就寫了一個小程式來測試這個問題。

測試程式可以在 https://github.com/starkwong/AndroidLayoutCollison 裡找到。

測試程式的原理很簡單,首先有一個程式庫 library1 內含 MainActivity,而它會使用 R.layout.activity_main (內含 ID 為 R.id.textview1 的 TextView),然後主程式也有 MainActivity 和 R.layout.activity_main (內含 ID 為 R.id.textview2 的 TextView)。library1 的 MainActivity 裡另加了修改 R.id.textview2 文字的語句,正常來說是完全沒問題的。然而當執行這個程式時,嘗試開啟 library1.MainActivity 的就會發生 NullPointerException:

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.mergetest/com.example.library1.MainActivity}: java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.TextView.setText(java.lang.CharSequence)' on a null object reference

這正正就是當時我們所遇到的問題!很明顯地當 Android SDK 進行資源合併時,可同時存在的 XML 內容可以合併 (例如是 strings.xml,colors.xml 等,而且由於裡面的 ID 會轉化成數字 ID 並對應到有區分 package 的 R 類,所以並不會有同名問題,除非是同一個 package 裡有兩個資源目錄結構有相同的 xml 時則會在編譯時報錯),但同名的 Layout XML 由於不能合併,Android SDK 現在的處理似乎只會使用其中一個,而我們遇到的問題估計是 build.gradle 中兩個 aar 的匯入次序不同所致。

衍生行為

再確認這個行為後,我突然又想到…既然我可以取代別人程式裡的 Layout XML,那麼我可以在不修改或 Extend 別人 Activity 的情況下修改顯示的內容或邏輯嗎?

於是我在上面的測試程式上再加入了 library2,裡面的 MainActivity 會使用 R.layout.activity_library (內含 ID 為 R.id.textview3 的 TextView),然後在整個 Layout XML 複製到主程式,並將 Root View 的 ConstraintLayout 改成我們自己的繼承版本 HijackConstraintLayout,並在裡面修改 R.id.textview3 的文字。

當執行測試程式時,我發現我的想法是完全正確,可以經由取代 Layout XML 來修改其他程式庫中 Activity 的行為,就算是把裡面按鈕的 OnClickListener 換掉也很簡單。

結論

從以上兩個測試,可以歸納出以下的結論:

  1. 不同套件間的同名 Layout XML,Android SDK 只會使用其中一個
  2. 但沒有使用的 Layout XML 裡面的 ID 仍然會被加到 R 類,所以不會編譯失敗
  3. 當第三方套件使用被替換的 Layout XML 後,引用該 Layout XML 不存在的 View 就會導致程式錯誤
  4. 第三方套件為免出現 Layout XML 被意外替換的問題,應該使用非常用的名稱
  5. 不同套件間的同名 ID 具有相同的資源編號,所以可以對第三方 Activity 進行 Layout XML 替換與邏輯修改

如何在 NGINX 進行多條件式流程控制

最近我們在加入一個新功能時需要與第三方合作,以識別第三方服務的使用者。然而過程並沒有想像中簡單,而且還涉及多重條件,雖然我們大可以使用 PHP 來處理這個問題,但由於該伺服器本身提供這個功能僅為兼任角色,可免的情況下也不想使用 FCGI,因為始終會消耗比較多系統資源。這裡簡單回顧一下整個過程。

  1. 伺服器回應特定標頭
    最初雙方協議好是第三方網絡在把回應傳給客戶端時會自動植入特定標頭,這樣客戶端程式只需要檢測出特定標頭的存在時就通過識別檢查。對伺服器端來說只需要提供一個特定 URL 就可以。這個在 NGINX 下的特定路徑設定就一句令伺服器提供空白回應:

    return 200 ‘’;

  2. 伺服器回傳特定標頭
    然後我們在 UAT 時發現客戶端並沒有收到特定的標頭,在對方查證後才發現原來他們植入特定標頭是植入到遠端請求而並非回應,這樣我們就需要設定伺服器端回傳特定的標頭 (這裡假設特定標頭為 X-Header1, X-Header2 及 X-Header3):

    add_header X-Header1 $http_x_header1;
    add_header X-Header2 $http_x_header2;
    add_header X-Header3 $http_x_header3;
    return 200 ‘’;

  3. 加入 IP 檢測
    在進行以上修改後再次測試發現,並且所有客戶端均能接收到其中一個標頭,經對方檢測後發現有技術問題導致出現問題,要解決就需要加入客戶端 IP 檢測,我們若檢測到符合 IP 範圍的時候就會取代 X-Header1 令客戶端可通過測試:

    (以下這段必須加到全域設定,也就是說無論是否相關的請求也會進行 IP 區域檢查)
    geo $client_ip $isp_geo {
    default 0;
    xxx.xxx.xxx.0/21 geo;
    yyy.yyy.yyy.0/21 geo;
    zzz.zzz.zzz.0/21 geo;
    255.255.255.255 -1;
    }

    (以下是特定路徑的設定)
    if ($isp_geo = "geo") {
    add_header X-Header1 ip;
    }
  4. if ($isp_geo != "geo") {
    add_header X-Header1 $http_x_header1;
    add_header X-Header2 $http_x_header2;
    add_header X-Header3 $http_x_header3;
    }
    return 200 ‘’;

  5. 需要先傳回特定標頭後才植入 ip
    雖然上列的修改基本上已經能解決偵測的問題,不過我方團隊想知道究竟有多少客戶能通過原來的標頭檢測。上列的修改雖然能解決檢測,但卻不能解決這個新要求,因為基本上會在伺服器端收到特定標頭的請求都必然通過 IP 測試,那樣就根本就不會看到有回傳真正的特定標頭的客戶。這樣的邏輯一般作法只需要用多個 if 條件及/或 if else 就能輕鬆解決。然而 NGINX 的 if 條件式是非常區限性:
    - if 裡面只能檢查單一條件
    - 不支援 else
    - 不支援巢狀式 if (即 if 區域裡面不可以再有 if)

    所以我們需要只用 if = 和 if != 才能解決問題,那就需要創造一個可讓 if 處理的值。既然我們知道 X-Header1, X-Header2 及 X-Header3 的值都可以是空值,那麼我們可以創造一個值來指出是否只有 IP 通過,例如:

    set $result "${http_x_header1}${http_x_header2}${http_x_header3}${isp_geo}";

    這樣,如果 $result 只有 “geo” 的話就代表所有特定標頭都不存在而只有 IP 檢測通過 (即等於 if ($http_x_header1 = ‘’ && $http_x_header2 = ‘’ && $http_x_header3 = ‘’ && $isp_geo = ‘geo’)),那時候我們就可以放心植入 ip 值到 X-Header1了。

    if ($result = "geo") {
    add_header X-Header1 ip;
    }
    if ($result != "geo") {
    add_header X-Header1 $http_x_header1;
    add_header X-Header2 $http_x_header2;
    add_header X-Neader3 $http_x_header3;
    }
    return 200 ‘’;

    這樣修改下,如果客戶有特定標頭,就會回傳特定標頭而不論是否通過 IP 檢測;而如果用戶沒有特定標頭但通過 IP 檢測,就只會回應 ip 作為 X-Header1 的值;而如果客戶兩個條件都不符合,則仍然會回傳特定標頭,但因為特定標頭不存在而不會進行回傳。

有關 APK 使用 Apktool 重建後無法在 Android 11 或以上的裝置

我在之前修改 APK 用 Apktool 重建後曾經試過安裝在 Android 11 或以上裝置會失敗,但由於再重建後問題又消失了,讓我以為是由於我用的 Apktool 版本問題所導致,誰知道今天又再次遇到同一個問題。

症狀

APK 在重建時不會遇到任何問題,但當使用 adb install 安裝時則會顯示如下的錯誤:

Performing Streamed Install
adb: failed to install xxx.apk: Failure [-124: Failed parse during installPackageLI: Targeting R+ (version 30 and above) requires the resources.arsc of installed APKs to be stored uncompressed and aligned on a 4-byte boundary]

其實這個問題在官方 issue tracker 裡也有報告 (https://github.com/iBotPeaches/Apktool/issues/2421),然而問題直到最新的 Apktool 2.6.0 仍然沒有解決。

原因及解決方法

從錯誤訊息中看到,目標為 Android 11 的 APK 中的 resources.arsc 檔案必須符合 2 個條件:

  1. 檔案必須沒有進行壓縮
  2. 檔案必須對齊到4位元組的邊界

將重建後的 APK 用壓縮程式開啟,可以看到 resources.arsc 的壓縮前後大小是相同的,也就是沒有進行過壓縮。至於要驗證4位元組邊界,我們可以使用 zipalign 工具,輸入 zipalign.exe -c -v 4 xxx.apk | find "resources.arsc" 的結果如下:

25786697 resources.arsc (BAD - 1)

果然 Apktool 並沒有正確地將 resources.arsc 對齊到 4 位元組的邊界。要解決這個問題,我們也可以使用 zipalign 工具 (zipalign.exe -p -f -v 4 input.apk output.apk) 將 APK 裡的檔案重新對齊一次,然後對輸出檔案再次驗證後的結果如下:

25787768 resources.arsc (OK)

確認過後,再將 APK 重新進行數位簽名後就再不會安裝失敗了 (注意數位簽名必須使用 Android Build Tools 裡的 apksigner,不能再使用 jarsigner)。

為什麼要對齊邊界?

大部份作業系統都支援將檔案直接對應成虛擬的記憶體區域 (*nix / Android 稱為 mmap,Windows 稱為 File Mapping),這樣的話就可以直接將檔案的區域指定到 C Struct、陣列等直接使用而不需要先分配記憶體,再將檔案中所需部份複製到記憶體後才能使用。然而系統本身的記憶體管理會有邊界要求,以進行優化及允許偵測問題,所以進行記憶體影射時也有同樣要求。

為什麼不能壓縮?

由於 resources.arsc 本身是一個二進制檔案,你可以把它看成是一個封裝檔或壓縮檔,檔案裡面除了有各被封裝檔案的內容外,也有列出封裝檔案的索引,而這索引有特定格式及位置,當使用 mmap 時就可以像記憶體般直接進行存取。如果將這個檔案壓縮過,索引就會成為被壓縮內容的一部份,也就是說除非先將索引解壓縮,否則無法直接進行存取。

診斷 AVPlayer 錯誤 -11848 及 -12925 的原因

我們打算容許 iOS 裝置播放 4K 的錄製內容,本來已經在 QA 環境設定好 VOS 的編碼格式並在 iPhone + iOS14 下測試通過,不過最近 QA 發現 iPhone 在連接 HDMI 時播放 4K 頻道或錄製內容都會顯示播放失敗。

該錯誤大致如下:

Optional(Error Domain=AVFoundationErrorDomain Code=-11848 "Cannot Open" UserInfo={NSUnderlyingError=0x156d78f30 {Error Domain=NSOSStatusErrorDomain Code=-12925 "(null)"}, NSLocalizedFailureReason=The media cannot be used on this device., NSLocalizedDescription=Cannot Open})

雖然錯誤訊息指明裝置無法播放該媒體,卻沒有指出是哪一個原因導致無法播放。從網上的搜尋結果沒有得到任何有用的資料,然而我在調查系統記錄時發現一些決定性的資訊。

無論是在 iOS、MacOS 甚至是 Safari 播放 HLS 串流時,系統都會透過 mediaserverd 進行解碼,而 -12925 的錯誤也是由 mediaserverd 所拋出的:

mediaserverd <SEGPUMP> segPumpSetCurrentAlternate: 0x1050ec000 0x1050ec000: Attempting SwitchToAlternate bw 896000, at time nan, duration nan 1
mediaserverd <SEGPUMP> segPumpCreateHTTPRequest: Byte pump 0x1050ec020 created new request 0x10a627280 for session ref 0x10a640040
mediaserverd <SEGPUMP> segPumpSendIndexFileRequest: 0x1050ec000:1 session 0x10a640040 index req []
mediaserverd <SEGPUMP> segPumpSetCurrentAlternate: starting audio group on stream index 1, switching from stream index 0, isTrial=0
mediaserverd <<<< FigStreamPlayer >>>> fpfs_ReportCriticalThroughFigLog: [QE Critical]fpfs_ReportVariantSwitchStart: [0x10d50e370]: <0x10411d000>: (11): starting switch up from [<FigAlternate:[0x0]>] to [<FigAlternate:[0x108ea4a80] [Peak 896000] [640x360] [AudioGroup audio1] [hvc1.1.6.L90.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 25.000]>]
mediaserverd <<<< FigStreamPlayer >>>> fpfs_SetAlternateWithContext: <0x10411d000|I/VBQ.01> substream 1 will change gear
mediaserverd <<<< FigStreamPlayer >>>> fpfs_PostNotificationFromDispatch: posting AlternateStreamChanged on 0x10411d000 (0x10a644820)
mediaserverd <<<< FigStreamPlayer >>>> fpfs_StopPlayingItem: [0x10d50e370|P/IP] <0x10411d000|I/VBQ.01> Pausing, err=-12925
mediaserverd <<<< FigStreamPlayer >>>> fpfs_haltStream: item <0x10411d000|I/VBQ.01> pump 0x1050ec000
mediaserverd <<<< FigStreamPlayer >>>> fpfs_haltStream: item <0x10411d000|I/VBQ.01> pump 0x0
mediaserverd <<<< FigStreamPlayer >>>> fpfs_ResetAudioHardwareFormat: [0x10d50e370|P/IP] No more audio rendering, set preferred channel count back to stereo

不過這裡並未看出任何主要線索,只能知道是開啟第一個 alternate 時就已經出現錯誤而停止播放。但是在這段記錯前,原來有一段標示為 HLS-FASB 的記錄清楚說明了系統播放器會套用的篩選器、播放串流的 alternate 以及套用篩選器的順序及結果。以下是在 iPhone 中播放 4K 頻道 (正常播放) 時的 FASB 篩選器記錄:

mediaserverd    <<HLS-FASB>> fasb_log: [0x10a70ade0:I/VNB.01]
<FigAlternateSelectionBoss:0x10a70ade0 [filterCount 13] [alternateCount 4] [filteredAlternateCount 3] [mediaSelectionArrayCount 2]>
   Monitors:
   {
     [FigAlternateFilterMonitorForHDCP currentMaxProtectedHDCPLevel:-1 currentEPM:(null)]
     [FigAlternateFilterMonitorForNotification<0x10a7335d0> "DisplayVideoRangeChanged" state:5 currentFilter:[FigAlternateFilter <PreferredVideoFormat:0x108ec8380> priority:800]]
     [FigAlternateFilterMonitorForNotification<0x1095fda70> "DisplayVideoRangeChanged" state:1 currentFilter:[FigAlternateFilter <FrameRateBucketCap:0x109513200> priority:700]]
     [FigAlternateFilterMonitorForNotification<0x108e941c0> "DisplayVideoRangeChanged" state:5 currentFilter:[FigAlternateFilter <SupportedVideoRange:0x10a729640> priority:1000]]
     [FigAlternateFilterMonitorForNotification<0x109582400> "PowerStateChanged" state:0 currentFilter:(null)]
   }
   Filters:
   {
mediaserverd    <<HLS-FASB>> fasb_log: [0x10a70ade0:I/VNB.01]
       4 ->  4: [FigSimpleAlternateFilter <SuppressVP9:0x10a42b1f0> priority:1100]
       4 ->  4: [FigSimpleAlternateFilter <SupportedVideoRange:0x10a729640> priority:1000 mode:[Internal (HDR OK)]]
       4 ->  4: [FigSimpleAlternateFilter <SupportedAudioFormat:0x10a427cf0> priority:1000 ac3IsDecodable:YES atmosIsDecodable:YES ec3IsDecodable:YES, ac3CanPassthrough:NO]
       4 ->  4: [FigSimpleAlternateFilter <MediaValidation:0x108ff8b30> priority:1000 allowUnknownCodecs:NO]
       4 ->  4: [FigSimpleAlternateFilter <AllowedCPC:0x10a45bf40> priority:1000 systemCPC:0xffffffffffffffff]
       4 ->  4: [FigMediaSelectionAudibleAlternateFilter <MediaSelectionAudible: 0x10a74dbf0> priority: 950 persistantIDs: 0]
       4 ->  4: [FigSimpleAlternateFilter <ScanModePreference:0x10a413ed0> priority:820 contiguous]
       4 ->  4: [FigHDCPAlternateFilter <HDCP: 0x10a734b20> priority: 810 hdcp0:Unknown hdcp1:Unknown]
       4 ->  4: [FigSimpleAlternateFilter <PreferredVideoFormat:0x108ec8380> priority:800 preferredRange:PQ preferredFormat:Maximum]
       4 ->  4: [CombinedAudioPreferenceFilter <CombinedAudioPreference: 0x10a71bba0> priority:750 channels:16 sampleRateConstraints:none preferLossyEncodings=NO preferredAudioFormat:Unknown ac3CanPassthrough:NO permitSpatialization=YES spatialAudioResolutionCutoffSize:1280.000000X720.000000 contentType=moov spatialSources=mlti]
       4 ->  4: [FigSimpleAlternateFilter <PreferBestFormatForVideoRange:0x108eaf060> priority:700 videoRange:SDR bestFormat:HEVC]
       4 ->  3: [FigSimpleAlternateFilter <DisplaySize:0x108ee7fc0> priority:700 displaySize:[2048x1152]]
       3 ->  3: [FigSimpleAlternateFilter <FrameRateBucketCap:0x109513200> priority:700 framerateBucketCap:60fpsBucket]
   }
   Alternates:
   {
     <FigAlternate:[0x103736550] [Peak 896000] [640x360] [AudioGroup audio1] [hvc1.1.6.L90.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 25.000]>
     <FigAlternate:[0x109559bf0] [Peak 2096000] [1280x720] [AudioGroup audio1] [hvc1.1.6.L120.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 50.000]>
     <FigAlternate:[0x109562fe0] [Peak 5096000] [1920x1080] [AudioGroup audio1] [hvc1.1.6.L123.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 50.000]>
     <FigAlternate:[0x109588640] [Peak 15096000] [3840x2160] [AudioGroup audio1] [hvc1.1.6.L153.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 50.000]>
   }
   Filtered Alternates:
   {
     <FigAlternate:[0x103736550] [Peak 896000] [640x360] [AudioGroup audio1] [hvc1.1.6.L90.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 25.000]>
     <FigAlternate:[0x109559bf0] [Peak 2096000] [1280x720] [AudioGroup audio1] [hvc1.1.6.L120.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 50.000]>
     <FigAlternate:[0x109562fe0] [Peak 5096000] [1920x1080] [AudioGroup audio1] [hvc1.1.6.L123.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 50.000]>
   }
LOG COMPLETE

在記錄中可以看到,串流中共有 4 個 alternate,而篩選器 11 至 13 分別是指系統最高可接受 SDR 的 HEVC、不多於 2048x1152 的解析度及不高於 60fps 的幀率,所以篩選結果是 4K 解析度的那個 alternate 因為超出 2048x1152 的解析度而被篩選掉,但其餘三個 alternate 仍能播放,只是最高只能顯示 1080p 的解析度而已。

現在讓我們來看看連接 HDMI 後的 HLS-FASB 記錄:

mediaserverd    <<HLS-FASB>> fasb_log: [0x108e902d0:I/VBQ.01]
<FigAlternateSelectionBoss:0x108e902d0 [filterCount 14] [alternateCount 4] [filteredAlternateCount 0] [mediaSelectionArrayCount 2]>
   Monitors:
   {
     [FigAlternateFilterMonitorForHDCP currentMaxProtectedHDCPLevel:-1 currentEPM:(null)]
     [FigAlternateFilterMonitorForNotification<0x10a6ef960> "DisplayVideoRangeChanged" state:5 currentFilter:[FigAlternateFilter <PreferredVideoFormat:0x10a6dcd50> priority:800]]
     [FigAlternateFilterMonitorForNotification<0x108fca140> "DisplayVideoRangeChanged" state:1 currentFilter:[FigAlternateFilter <FrameRateBucketCap:0x10a67e600> priority:700]]
     [FigAlternateFilterMonitorForNotification<0x10a6efd60> "DisplayVideoRangeChanged" state:5 currentFilter:[FigAlternateFilter <SupportedVideoRange:0x10a63fbd0> priority:1000]]
     [FigAlternateFilterMonitorForNotification<0x10a67e8e0> "PowerStateChanged" state:0 currentFilter:(null)]
   }
   Filters:
   {
       4 ->  4: [FigSimpleAlternateFilter <SuppressVP9:0x1095a5660> priority:1100]
       4 ->  4: [FigSimpleAlternateFilter <SupportedVideoRange:0x10a63fbd0> priority:1000 mode:[Internal (HDR OK)]]
       4 ->  0: [FigSimpleAlternateFilter <NeroSupportedVideoFormat:0x10a650c80> priority:1000 preferredRange:SDR preferredFormat:AVC]
       0 ->  0: [FigSimpleAlternateFilter <SupportedAudioFormat:0x109521590> priority:1000 ac3IsDecodable:YES atmosIsDecodable:NO ec3IsDecodable:YES, ac3CanPassthrough:NO]
       0 ->  0: [FigSimpleAlternateFilter <MediaValidation:0x109505220> priority:1000 allowUnknownCodecs:NO]
       0 ->  0: [FigSimpleAlternateFilter <AllowedCPC:0x109586db0> priority:1000 systemCPC:0xffffffffffffffff]
       0 ->  0: [FigMediaSelectionAudibleAlternateFilter <MediaSelectionAudible: 0x10a67d420> priority: 950 persistantIDs: 0]
       0 ->  0: [FigSimpleAlternateFilter <ScanModePreference:0x10a628e90> priority:820 contiguous]
       0 ->  0: [FigHDCPAlternateFilter <HDCP: 0x10a6ef8d0> priority: 810 hdcp0:Unknown hdcp1:Unknown]
       0 ->  0: [FigSimpleAlternateFilter <PreferredVideoFormat:0x10a6dcd50> priority:800 preferredRange:PQ preferredFormat:Maximum]
       0 ->  0: [CombinedAudioPreferenceFilter <CombinedAudioPreference: 0x10a67e1b0> priority:750 channels:2 sampleRateConstraints:none preferLossyEncodings=NO preferredAudioFormat:Unknown ac3CanPassthrough:NO permitSpatialization=NO spatialAudioResolutionCutoffSize:1280.000000X720.000000 contentType=moov spatialSources=none]
       0 ->  0: [FigSimpleAlternateFilter <PreferBestFormatForVideoRange:0x10a624a80> priority:700 videoRange:SDR bestFormat:Unknown]
       0 ->  0: [FigSimpleAlternateFilter <DisplaySize:0x10a640100> priority:700 displaySize:[1920x1080]]
       0 ->  0: [FigSimpleAlternateFilter <FrameRateBucketCap:0x10a67e600> priority:700 framerateBucketCap:60fpsBucket]
   }
   Alternates:
   {
     <FigAlternate:[0x108ea4a80] [Peak 896000] [640x360] [AudioGroup audio1] [hvc1.1.6.L90.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 25.000]>
     <FigAlternate:[0x1095aafb0] [Peak 2096000] [1280x720] [AudioGroup audio1] [hvc1.1.6.L120.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 50.000]>
     <FigAlternate:[0x109559bf0] [Peak 5096000] [1920x1080] [AudioGroup audio1] [hvc1.1.6.L123.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 50.000]>
     <FigAlternate:[0x109588640] [Peak 15096000] [3840x2160] [AudioGroup audio1] [hvc1.1.6.L153.B0,mp4a.40.2] [VideoRange SDR] [FrameRate 50.000]>
   }
   Filtered Alternates:
   {
   }
LOG COMPLETE

從這次的篩選記錄可以看到,系統現在支援的格式只有 SDR 格式的 AVC,最高 1920x1080 解析度及最高 60fps 的幀率,而由於所有 alternate 均是 HEVC 編碼,所以所有 alternate 都在經過第 3 個篩選器時被篩選掉了,以致最後沒有可以播放的 alternate 而導致 -12925 的錯誤,也就是說 iOS 支援 HEVC 解碼只限使用內建輸出,當連接 HDMI 時是不支援 HEVC 解碼的。

未預期的 AndroidX Fragment 更新

最近因為某個奇怪問題而嘗試把程式中的第三方程式庫版本更新,而當把 Dagger 和 AndroidX 更新的時候發現一個很奇怪的問題。

我們的程式由於可以在程式中變更語言,所以為方便不需要在界面初始化時自行設定正確語言的文字,有在一些地方進行特別設定 Locale (包括系統 Locale 及資源 Locale),但當把 Dagger 更新後,發現變更語言後有很多地方都無法套用新的語言設定。

據我們初步的測試所知,當把 Dagger 2.9 或 AndroidX AppCompat 1.1 更新後就會發生這個情況。在調查過兩者的 pom 檔案後發現,Dagger 2.37 和 AppCompat 1.3.0 都有一個共通點,就是 AndroidX Fragment 的版本被更新到 1.3.0 或以上的版本。

然後我們再看看 AndroidX Fragment 的更新記錄 (https://bit.ly/3tgLdUN),Bingo! 原來 Google 在 AndroidX 1.3.0alpha08 的時候引入了重寫的 State Manager 而且預設使用,而似乎這個新 State Manager 的行為與舊版有點分別而導致問題。

那麼我們該如何解決那個問題呢?幸好目前可以使用 FragmentManager.enableNewStateManager(false) 來強制使用舊的 State Manager,經我們測試過果然有效。然而,根據官方網誌 (https://bit.ly/38WBlGJ) 所述,這個方法並不視為官方 API 且會在 1.3.1 版考慮移除 (不過目前最新版本還沒有看到被移除)。那就是說長遠來看,我們還是得想辦法在新的 State Manager 尋找出路,除非 Google 注意到這個問題而進行修正。

Android 程式斷網化 - Firebase 篇

當一個程式被切斷網路通訊 (見上一篇) 後,若程式有植入 Firebase 時,程式啟動後短時間就會自動關閉且顯示如下的錯誤記錄:

FATAL EXCEPTION: firebase-installations-executor-2
Process:                           , PID: 3006
java.lang.SecurityException: Permission denied (missing INTERNET permission?)
        at java.net.Inet6AddressImpl.lookupHostByName(…)
        at java.net.Inet6AddressImpl.lookupAllHostAddr(…)
        at java.net.InetAddress.getAllByName(InetAddress.java:1154)
        at com.android.okhttp.Dns$1.lookup(Dns.java:39)
        at com.android.okhttp.internal.http.RouteSelector.resetNextInetSocketAddress(…)
        at com.android.okhttp.internal.http.RouteSelector.nextProxy(…)
        at com.android.okhttp.internal.http.RouteSelector.next(…)
        at com.android.okhttp.internal.http.StreamAllocation.findConnection(…)
        at com.android.okhttp.internal.http.StreamAllocation.findHealthyConnection(…)
        at com.android.okhttp.internal.http.StreamAllocation.newStream(…)
        at com.android.okhttp.internal.http.HttpEngine.connect(…)
        at com.android.okhttp.internal.http.HttpEngine.sendRequest(…)
        at com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute(…)
        at com.android.okhttp.internal.huc.HttpURLConnectionImpl.connect(…)
        at com.android.okhttp.internal.huc.HttpURLConnectionImpl.getOutputStream(…)
        at com.android.okhttp.internal.huc.DelegatingHttpsURLConnection.getOutputStream(…)
        at com.android.okhttp.internal.huc.HttpsURLConnectionImpl.getOutputStream(…)
        at com.google.firebase.installations.s.c.a(Unknown Source:0)
        at com.google.firebase.installations.s.c.a(Unknown Source:8)
        at com.google.firebase.installations.s.c.a(Unknown Source:53)
        at com.google.firebase.installations.h.d(Unknown Source:45)
        at com.google.firebase.installations.h.d(Unknown Source:34)
        at com.google.firebase.installations.h.b(Unknown Source:0)
        at com.google.firebase.installations.b.run(Unknown Source:4)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
        at java.lang.Thread.run(Thread.java:764)

雖然從 Stack Trace 中看到也是 okhttp.Dns$1.lookup 拋出的錯誤,但由於 com.android.okhttp 是存在於系統的套件,無法直接在程式源碼層面中修改。要避過 Firebase 的斷網問題,有至少兩個方法可以實行。

1. 停止 Firebase 的自動初始化功能

有進行過植入 Firebase 的網友都應該記得,我們並不需要寫任何編碼 Firebase 就能自動初始化。究竟 Firebase 是如何自動初始化的呢?根據官方解釋 (https://firebase.googleblog.com/2016/12/how-does-firebase-initialize-on-android.html),Firebase 是利用 Content Provider 會自動被系統建立的特性來故意濫用作自動初始化 Firebase 的,所以最簡單迴避 Firebase 初始化的方法就是把 AndroidManifest 裡如下列的定義刪掉:

<provider android:authorities="                          .firebaseinitprovider" android:directBootAware="true" android:exported="false" android:initOrder="100" android:name="com.google.firebase.provider.FirebaseInitProvider"/>

這個方法對只使用單一 Firebase App 的程式上沒有問題,不過對於會手動呼叫 FirebaseApp.initializeApp() 的程式則沒有作用,需要使用方法 2 解決。

2. 強制初始化失敗

對於會呼 FirebaseApp.initializeApp(),我們需要參照上列的 Stack Trace 研究可以防止初始化的方法。首先在com\google\firebase\installations\s\c.smali 中尋找呼叫 getOutputStream() 的位置,然後可以確定是在 private static void a(URLConnection p0, byte[] p1)。往下面看一點,:cond_0 是拋出 IOException 的地方,我們又可以利用這個地方進行拋出。在 getOutputStream() 那一句前面加 goto :cond_0 作無條件式跳到 :cond_0 就完成修改。

在防止 Firebase 初始化後,程式就可以完全斷網而不會因沒有定義 INTERNET 權限而發生錯誤。

Android 程式斷網化 - 從第三方程式庫着手

要將一個本身可離線使用的程式徹底斷網,其實並不需要修改每一個發出連線要求的位置。其實無論是原生程式,還是 React Native 程式,絕大多數不是使用第三方程式庫就是有一個中央的地方處理網路要求。所以只需要修改這個中央的地方就可以將所有網路要求都處理掉。

當去除 INTERNET 權限後,React Native 程式在執行時出現這樣的致命錯誤:

--------- beginning of crash
FATAL EXCEPTION: OkHttp Dispatcher
Process:                           , PID: 2938
java.lang.SecurityException: Permission denied (missing INTERNET permission?)
        at java.net.Inet6AddressImpl.lookupHostByName(Inet6AddressImpl.java:151)
        at java.net.Inet6AddressImpl.lookupAllHostAddr(Inet6AddressImpl.java:105)
        at java.net.InetAddress.getAllByName(InetAddress.java:1154)
        at okhttp3.Dns$1.lookup(Unknown Source:2)
        at okhttp3.internal.connection.RouteSelector.resetNextInetSocketAddress(Unknown Source:129)
        at okhttp3.internal.connection.RouteSelector.nextProxy(Unknown Source:20)
        at okhttp3.internal.connection.RouteSelector.next(Unknown Source:17)
        at okhttp3.internal.connection.StreamAllocation.findConnection(Unknown Source:109)
        at okhttp3.internal.connection.StreamAllocation.findHealthyConnection(Unknown Source:0)
        at okhttp3.internal.connection.StreamAllocation.newStream(Unknown Source:22)
        at okhttp3.internal.connection.ConnectInterceptor.intercept(Unknown Source:25)
        at okhttp3.internal.http.RealInterceptorChain.proceed(Unknown Source:158)
        at okhttp3.internal.http.RealInterceptorChain.proceed(Unknown Source:6)
        at okhttp3.internal.cache.CacheInterceptor.intercept(Unknown Source:132)
        at okhttp3.internal.http.RealInterceptorChain.proceed(Unknown Source:158)
        at okhttp3.internal.http.RealInterceptorChain.proceed(Unknown Source:6)
        at okhttp3.internal.http.BridgeInterceptor.intercept(Unknown Source:161)
        at okhttp3.internal.http.RealInterceptorChain.proceed(Unknown Source:158)
        at okhttp3.internal.http.RetryAndFollowUpInterceptor.intercept(Unknown Source:48)
        at okhttp3.internal.http.RealInterceptorChain.proceed(Unknown Source:158)
        at okhttp3.internal.http.RealInterceptorChain.proceed(Unknown Source:6)
        at okhttp3.RealCall.getResponseWithInterceptorChain(Unknown Source:115)
        at okhttp3.RealCall$AsyncCall.execute(Unknown Source:11)
        at okhttp3.internal.NamedRunnable.run(Unknown Source:17)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
        at java.lang.Thread.run(Thread.java:764)

由於 React Native 的網路部份是使用 OkHttp3,所以當進行網路要求的時候自然也是由 OkHttp3 拋出錯誤。直接拋出 SecurityException 的位置 java.net.Inet6AddressImpl 由於是系統套件,我們無法修改。但我們可以從最接近系統呼叫的地方 okhttp3.Dns$1 找尋修改的機會。

okhttp_dns_1

上圖標示的位置就是拋出 SecurityException 的呼叫。要注意的是 Stack Dump 中所標示的第二行,由於 smali 已被移除行號,所以無法直接對應正確的位置,這時候只能透過方法名稱 (即 lookup) 來尋找正確的方法。在這裡有好幾個解決方法,例如將該句 invoke-static 替換成拋出 UnknownHostException,或將參數 p1 替換成不會構成 SecurityException 的地址 (例如 127.0.0.1)。不過這裡可以用另外一個更簡單的方法。從 invoke-static 該句向上看,有一句 if-eqz p1, :cond_0,這個用 Pseudo code 的意思就是 if (p1 == null) goto cond_0。而 cond_0 又是什麼呢?

okhttp_dns_2

Bingo! 也就是說如果將 null 傳入 p1 的時候會拋出 UnknownHostException,這部份正是我們想要的結果,那麼我們只需要修改跳到 cond_0 的條件即可。if-eqz p1, :cond_0 這句我們只需要由 if-eqz (如果等於 0) 改成 if-nez (如果不等於 0) 就可以將所有有效參數都全部導向 cond_0 而拋出 UnknownHostException。

這樣就已經處理大部份的網路要求。然後還剩下什麼呢?當程式使用 Firebase 的時候,Firebase 初始化時也會拋出 SecurityException,這個會在下一篇再討論。

將 Android 程式斷網的方法

要說最近最多人修改離線化的程式肯定是某個宣稱不會上傳資料的程式。相信有試過動手的人都會發現 Android 裡要令一個程式斷網,純粹把 AndroidManifest.xml 裡的 android.permission.INTERNET 權限請求刪除是不行的,因為系統會拋出 SecurityException,而且因為 SecurityException 並非繼承自 IOException,所以當程式執行時會導致 FC 而不是被當成網路異常被處理掉。

再令整件事複雜一點,該程式是用 ReactNative 開發的,也就是要修改的話要先拆解 index.android.bundle 然後再修改。不過事實真的需要這樣做嗎?

在之後的 2 篇文章中將講解如何以我認為最簡單的方法迴避 SecurityException,另外會再有幾篇相關的文章講解其他技巧。多數文章都需要對 Dalvik Opcodes 有一定認識。

* 注意:本系文章僅供學術交流使用,文章中並不會提供確實的修改方法,亦不會提供任何下載。

screenshot-2021-08-14_22.20.33.491

iOS + Android 的 IMA SDK 與 Safe Area Insets

最近在為某個播放程式加入 VAST 支援,由於目前使用第三方播放器的 VAST 支援並不能完全支援 Google Ads Manager,所以決定使用官方的 IMA SDK。不過官方的 IMA SDK 有一個缺點,就是不讓開發者自行定義界面 (可以設定是否隱藏或顯示某些項目,但不能自行作出其他調整。雖然官方說明中有可搜尋但沒有列出的頁面說明如何修改,但官方有解釋並不支援:https://groups.google.com/g/ima-sdk/c/yVBKLyO9jfY/m/dzYerQH-AQAJ),所以我們會使用自帶的廣告界面。

然而,在 QA 測試時發現無論是 iOS 或是 Android,當裝置螢幕為圓角 (例如 iPhone X, Samsung A70) 的時候,在左下角顯示的倒數時間及右上角的 “瞭解更多” 字樣均超出了圓角的範圍而導致無法完全顯示。

我們的設計其實比較簡單,播放器的容器就只會放第三方播放器和 IMA SDK 的控制項:

  • 根容器 (佔全螢幕包括所有安全區域)
    • PlayerContainer (UIView / FrameLayout)
      • IMA SDK 控制項 (IMA SDK 會自動放在最頂端)
      • 第三方播放器
    • 播放器控制
    • 其他重疊顯示

iOS 的處理相對簡單,只需要在 PlayerContainer 與 IMA SDK 之間加一個左右邊符合 playerContainer.safeAreaLayoutGuide 的 UIView 就可以。

Android 就麻煩得多,Google 在 API level 20 (Android 4.4W) 的時候加入了 View.setOnApplyWindowInsetsListener(),理論上可以接收到系統要求特定 View 套用所要求的 WindowInsets,但經測試後發現在全螢幕 (作為播放器,當然會用 SYSTEM_UI_FLAG_FULLSCREEN 及 SYSTEM_UI_FLAG_IMMERSIVE) 時,就算設定了 WindowInsetsListener 系統根本不會呼叫,所以沒用。

然後改為試用 API level 23 (Android 6) 加入的 View.getRootWindowInsets(),但只有在 View.onAttachedToWindow() 的時候呼叫時,WindowInsets.displayCutout 才不是 null,也就是我不能在初始化 IMA SDK 的時候就已經設定好 Margins。

最後,我從 stackoverflow 找到這篇方法 (https://stackoverflow.com/questions/61241255/windowinsets-getdisplaycutout-is-null-everywhere-except-within-onattachedtowindo)。

透過 Activity.getWindowManager().getDefaultDisplay().getCutout() 的確可以正確地取得不包括圓角區域的顯示範圍,不過最大的缺點是這是 API Level 29 (Android 10) 的 API,然而只有這個方法能正確取得可用的數值,所以也只能這樣了。

不過使用時有一點要留意,估計由於計算預設 Cutout 的時候並不假定 Immersive Mode,所以機頂向左的橫屏時只有 getSafeInsetLeft() 會傳回有效數值而 getSafeInsetRight() 會傳回 0,而機頂向右的時候則剛好相反,所以我們在套用 Margin 的時候會在左右兩邊同時套用任何一邊的有效數值,由於目前應該沒有裝置會在橫屏時是左圓角右方角,這個假定應該暫時還是可接受的。