Stark Wong 的個人開發網站
 


 此頁面:更新於 2023 年 8 月 7 日 23 時 21 分 33 秒,頁面處理需時 0.0081 秒
 網站內容版權所有(C)Stark Wong。頁面(不包括檔案)可自由連結。網站系統版本 1.90-AngularJSBase (2015/9/27)
 
網站地圖

來讓 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 找到。



撰寫於:2024/11/24 22:16:00 / 回應:1
正在讀取回響內容...
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.



撰寫於:2024/11/16 19:15:00 / 回應:0
正在讀取回響內容...
公用服務: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
撰寫於:2023/9/16 22:44:00 / 回應:0
正在讀取回響內容...
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 也有出現。


撰寫於:2023/8/7 22:44:00 / 回應:0
正在讀取回響內容...
如何在 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 版本相容。


撰寫於:2023/6/14 14:49:15 / 回應:0
正在讀取回響內容...
去網路化 - 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 的去網路化完成。


撰寫於:2022/5/25 18:00:09 / 回應:0
正在讀取回響內容...
請小心為你的 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 替換與邏輯修改

撰寫於:2022/1/23 17:08:53 / 回應:0
正在讀取回響內容...
如何在 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 的值;而如果客戶兩個條件都不符合,則仍然會回傳特定標頭,但因為特定標頭不存在而不會進行回傳。


撰寫於:2021/12/19 14:21:02 / 回應:0
正在讀取回響內容...
有關 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 時就可以像記憶體般直接進行存取。如果將這個檔案壓縮過,索引就會成為被壓縮內容的一部份,也就是說除非先將索引解壓縮,否則無法直接進行存取。


撰寫於:2021/11/26 22:08:01 / 回應:0
正在讀取回響內容...
診斷 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 解碼的。


撰寫於:2021/9/20 00:01:47 / 回應:0
正在讀取回響內容...
其他較舊內容請移步至舊部落格版面