【Android】對Resources資源加密

【Android】對Resources資源加密

有一個很神秘的需求,需要對專案內的圖檔進行加密,但又不能影響到專案的開發。於是在 Github 上發現了一個看似可用的解決方案,下面就來說說這是怎麼做到的。

原理

在 Android Studio 當中,要透過 gradle 打包 apk 的時候,我們可以透過 addBuildListener 來監聽整個打包的過程。在 addBuildListener 這個 Listener 裡面,有幾個方法: settingsEvaluated()projectsLoaded()projectsEvaluated()buildFinished() 。這邊我們專注在projectsEvaluated()buildFinished() 這兩個方法,其中projectsEvaluated() 會是在打包 apk 前執行的,而buildFinished() 則是會在打包完成後執行。因此我們可以利用這兩個方法,來對圖檔進行加密,在要打包成 apk 時,我們將圖檔們加密並放入 apk 中,等打包結束後,我們在把原檔還原回來。

至於 apk 在執行的當下要如何解密呢?如果我們直接把加密完的檔案直接放回 res 資料夾底下,android 會解析不了造成錯誤,所以我們需要繞點路,將加密後的檔案放在其他地方,再把他讀出來解密後,轉成圖檔再餵給需要的 view 。這們我們選擇把加密後的檔案放在 assets 資料夾底下,當要用到圖檔的時候,透過 AssetManager 把檔案讀出來,做後續的動作。

透過這種方式,在開發的過程中,所有圖檔都還是原檔,因此我們在編輯 layout 的時候,可以用 tools:src="@drawable/image" 的方式來將圖片顯現,而真正需要用到圖檔的時候,再利用程式去解密轉圖檔出來用。

實作

了解了整個流程,那麼就可以開始來實作了,簡單可以拆分為兩個部分

  • 加密:於 gradle script 中進行,複製原始圖檔備份 → 將圖檔加密 → 刪除原檔  →  加密後圖檔移動到 assets 資料夾中 → 打包 apk → 刪除位於 assets 中已加密的圖檔 → 將備份的原始圖檔還原至 res 資料夾 → 刪除備份圖檔
  • 解密:寫在專案程式中,從 assets 資料夾中讀取圖檔 → 解密 → 轉換為需要用的格式

encrypt.gradle

ext {
    skipXmlFile = true
    encryptKey = '*******************'

    resourcePath = './app/src/main/res'
    assetsPath = './app/src/main/assets'
    tempPath = './tempDir'
    resDir = ['raw', 'drawable-hdpi', 'drawable-xhdpi', 'drawable-xxhdpi', 'drawable-xxxhdpi']
    assetsFiles = []
}

在開始寫程式前我們先宣告一些變數:

  • skipXmlFile: 用於設定等等的加密過程中要不要避開 xml 檔案
  • encryptKey: 加密的密碼
  • resourcePath: 要加密的根目錄
  • assetsPath: 要存放加密檔案的 assets 資料夾路徑
  • tempPath: 放置備份圖檔的位置
  • resDir: 需要加密的子資料夾,後面會以 resourcePath + resDir 的方式使用
  • assetsFiles: 放置加密的檔案清單,用於後續刪除時使用,避免動到原有的 assets 檔案
gradle.addBuildListener(new BuildListener() {
    @Override
    void settingsEvaluated(Settings settings) {}

    @Override
    void projectsLoaded(Gradle gradle) {}

    @Override
    void projectsEvaluated(Gradle gradle) {
        (new File(tempPath)).mkdirs()
        (new File(assetsPath)).mkdirs()
        
        for(String dir in resDir) {
            copyFolder(resourcePath + File.separator + dir, tempPath + File.separator + dir)
            encodeDir(resourcePath + File.separator + dir, assetsPath + File.separator + dir, assetsFiles)
            deleteDir(resourcePath + File.separator + dir, false)
        }
    }

    @Override
    void buildFinished(BuildResult result) {
        for(String dir in resDir) {
            copyFolder(tempPath + File.separator + dir, resourcePath + File.separator + dir)
            deleteDir(tempPath + File.separator + dir, true)
        }
        deleteDir(tempPath, true)
        deleteAssetsFiles(assetsFiles)
    }
})

接著我們來加入監聽器:

  • projectsEvaluated
    首先我們先準備好要存放檔案的兩個位置,為了確保後續不會出錯,所以先利用 mkdirs() 把資料夾建立起來。接著利用 for 迴圈來對 resDir 陣列中的資料夾進行複製、編碼、刪除的動作。
  • buildFinished
    當 apk 打包完成後,以 for 迴圈來把 resDir 中的資料夾圖檔進行還原、刪除備份檔案,最後刪除 assets 中的加密檔案。
void copyFolder(String oldPath, String newPath) {
    File newFile = new File(newPath)
    File oldFile = new File(oldPath)
    String[] files = oldFile.list()

    newFile.mkdirs()

    for(String file in files) {
        File temp = new File(oldPath, file)
        if(temp.isFile()) {
            FileInputStream input = new FileInputStream(temp)
            FileOutputStream output = new FileOutputStream(newPath + File.separator + temp.name)
            byte[] bytes = new byte[1024 * 5]
            int len
            while ((len = input.read(bytes)) != -1) {
                output.write(bytes, 0, len)
            }
            output.flush()
            output.close()
            input.close()
        }

        if(temp.isDirectory()) {
            copyFolder(oldPath + File.separator + file.name, newPath + File.separator + file.name)
        }
    }
}

複製檔案的部分算是簡單,傳入要複製的資料夾路徑,以及要輸出的資料夾路徑。一樣透過 for 迴圈來把資料夾內的檔案歷遍,如果是檔案,那麼就把他複製到要輸出的資料夾中,如果不是檔案的話,那就遞迴自己呼叫自己,再去對子資料夾做一樣的事情。

private String getFileExtension(File file) {
    return file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase()
}

這部分只是用來抓取檔案的副檔名而已。

void deleteFile(File file, Boolean skipXml) {
    String extension = getFileExtension(file)

    if (!file.exists()) return
    if (skipXml && extension == "xml" && !file.isDirectory()) {
        println "delete file: [SKIP] " + file.getAbsolutePath()
        return
    }
    if (file.isFile()) {
        file.delete()
        println "delete file: " + file.getAbsolutePath()
        return
    }

    for (File subFile in file.listFiles()) {
        deleteFile(subFile, skipXml)
    }
    file.delete()
}

void deleteDir(String path, Boolean isTemp) {
    def skipXml = !isTemp && skipXmlFile
    deleteFile(new File(path), skipXml)
}

void deleteAssetsFiles(ArrayList<File> assetsFiles) {
    for (File file in assetsFiles)
        deleteFile(file, false)

    File assetsDir = new File(assetsPath)
    if(assetsDir.listFiles().length == 0)
        deleteFile(assetsDir, false)
}
  • deleteFile
    刪除資料的部分,這裡要分兩種情況,一個是要打包 apk 時要刪除原本 res 底下的檔案,跟最後要刪除暫存資料夾兩種情況,然後還要把前面宣告的 skipXmlFile 考慮進來。

    一開始檢查檔案存不存在,不存在就直接 return 不處理。

    如果檔案存在,而且要跳過 xml 檔案,並且剛好這個檔案就是 xml 檔,那麼一樣 return 跳過,然後印出 log。

    至此例外狀況處理完畢,後面就只要是檔案就刪掉,不是檔案就歷遍遞迴。
  • deleteDir
    主要呼叫的是這個方法,只要傳進來要刪除的資料夾路徑,以及他是不是暫存資料夾即可,程式這邊去產出對應的 skipXml 布林值來丟給 deleteFile()
  • deleteAssetsFiles
    傳入 assetsFiles 來一個一個把檔案刪掉,最後只是檢查 assets 底下是不是為空,如果是那就連 assets 資料夾也一起刪掉。
private byte[] encrypt(byte[] content) {
    byte[] keyStr = encryptKey.getBytes()
    SecretKeySpec key = new SecretKeySpec(keyStr, "AES")
    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
    cipher.init(Cipher.ENCRYPT_MODE, key)
    byte[] result = cipher.doFinal(content)
    return result
}

void encodeDir(String rawDir, String assetsPath, ArrayList<File> assetsFiles){
    println "========== Start encode dir ==========\n[dir name] " + rawDir
    File dir = new File(rawDir);
    if (dir.exists() && dir.isDirectory()) {
        for (File file : dir.listFiles()) {
            if(skipXmlFile && getFileExtension(file) == "xml") {
                println "encode dir process: [SKIP] " + rawDir + File.separator  + file.name
                continue
            }
            println "encode dir process: " + rawDir + File.separator + file.name

            File targetFile = new File(assetsPath, file.name)
            if(!targetFile.parentFile.exists()) {
                targetFile.parentFile.mkdirs()
                assetsFiles.add(targetFile.parentFile)
            }
            assetsFiles.add(targetFile)

            def stream = targetFile.newOutputStream()
            def input = new FileInputStream(file)
            def buffer = new ByteArrayOutputStream()

            int nRead
            byte[] data = new byte[16384]

            while ((nRead = input.read(data, 0, data.length)) != -1) {
                buffer.write(data, 0, nRead)
            }
            def content = encrypt(buffer.toByteArray())
            stream.write(content)
            stream.flush()
        }
    }
    println "========== Encode dir finish ==========\n"
}

加密的部分:

  • encrypt
    主要的加密核心就是這一段,這邊是透過 AES 來近行加密的。
  • encodeDir
    將要加密的資料夾路徑,要輸出的資料夾入進,以及前面宣告的 assetsFiles 傳入,先判斷資料夾是否存在,及是否真的是資料夾才開始處理。首先先歷遍資料夾內的檔案,並檢查 skipXmlFile 有沒有開啟,以及副檔名是不是 xml ,如果都符合那就跳過這個檔案不處理並印出 log ,其他的接續處理。先檢查輸出位置的資料夾是否存在,沒有就建立並加入 assetsFiles 裡面,然後把加密的檔案也加入 assetsFiles 裡面。接著把檔案讀出來變成 stream 並餵給 encrypt() 來進行加密。

至此,加密部分完成。

AESUtil.kt

fun getDrawable(context: Context, resourceId: Int): Drawable {
    val byteArray = decrypt(context, resourceId, false)
    val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)

    return BitmapDrawable(context.resources, bitmap)
}

fun getTextFile(context: Context, resourceId: Int): String {
    val byteArray = decrypt(context, resourceId)
    return String(byteArray)
}

先來看看,最後被叫用的接口長怎樣。 getDrawable()getTextFile() 分別是取圖檔及取文字的兩個方式,我們希望只要傳 context 跟 resrouceId 也就是 R.drawable.image 或是 R.raw.textfile 這種東西進來就可以取得檔案內容。我們先看一下兩個方法的內部,都是先透過 decrypt() 這個方法先去取得檔案的 byteArray 後,在對其轉換成對應的型態並返回給呼叫者。

private fun decrypt(context: Context, resourceId: Int, isVector: Boolean = false): ByteArray {
    val sourceStream = context.assets.open(getResourceMappingPath(context, resourceId, isVector))
    val keyStr = BuildConfig.AES_KEY.toByteArray()
    val key = SecretKeySpec(keyStr, "AES")
    val cipher: Cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
    cipher.init(Cipher.DECRYPT_MODE, key)
    return cipher.doFinal(sourceStream.readBytes())
}

至於 decrypt() 方法裡面做了什麼事呢?其實只是很簡單的去對應出 resourceId 在 assets 資料夾內對應的檔案位置而已,這邊透過了另一個 getResourceMappingPath() 的方法來取得,接著就是對得的檔案 stream 做解密的動作並返回。

private fun getResourceMappingPath(context: Context, resourceId: Int, isVector: Boolean = false): String {
    val resourceType = context.resources.getResourceTypeName(resourceId)
    val resourceDir = if(resourceType.equals("drawable")) getResourceFolderName(context, isVector) else resourceType
    val resourceName = context.resources.getResourceEntryName(resourceId)
    var result = ""
    if(!context.assets.list("")?.filter { it.equals(resourceDir) }.isNullOrEmpty()) {
        val file = context.assets.list(resourceDir)?.first { it.startsWith(resourceName) }
        if(!file.isNullOrEmpty()){
            result = "$resourceDir/$resourceName.${file.replace("$resourceName.", "")}"
        }
    }
    return result
}

private fun getResourceFolderName(context: Context, isVector: Boolean = false): String {
    if(isVector) return "drawable"

    when (context.resources.displayMetrics.densityDpi) {
        DisplayMetrics.DENSITY_260, DisplayMetrics.DENSITY_280, DisplayMetrics.DENSITY_300, DisplayMetrics.DENSITY_XHIGH -> return "drawable-xhdpi"
        DisplayMetrics.DENSITY_340, DisplayMetrics.DENSITY_360, DisplayMetrics.DENSITY_400, DisplayMetrics.DENSITY_420, DisplayMetrics.DENSITY_440, DisplayMetrics.DENSITY_XXHIGH -> return "drawable-xxhdpi"
        DisplayMetrics.DENSITY_560, DisplayMetrics.DENSITY_XXXHIGH -> return "drawable-xxxhdpi"
    }
    return "drawable-hdpi"
}

由於我們是直接對整個 res 資料夾下的檔案進行加密,因此同一個 drawable 的 resourceId 會有好多張不同解析度的圖檔,在 getResourceFolderName() 的這個方法裡就是來判斷裝置的解析度應該用對應的哪一張解析度圖檔,其中參數的 isVector 是用來判斷是不是向量圖,或是像是 shape 的這種 xml,但因為目前沒有支援這類圖檔的解析,所以這參數等於沒作用。

getResourceMappingPath() 的部分,則是透過 Android 的 Resource API 來先對 resourceId 進行解析,先抓出 resourceId 的型態,看是 drawable 或是其他種類,若是 drawable 的話就會透過上述的 getResourceFolderName() 再去判斷是屬於哪個解析度的,接著一樣透過 Resource API 來取得 resourceId 的真實檔名,但由於這個檔名不會包含副檔名,所以最後透過 filter 的方式去找檔名一致的檔案,並回傳路徑。

使用

最後來看看要怎麼使用吧。

apply(from: 'encrypt.gradle')

android {
    defaultConfig {
        buildConfigField 'String', 'AES_KEY', "\"$encryptKey\""
    }
}
build.gradle

首先現在 app 層的 build.gradle 裡面,將 encrypt.gradle引入,並透過 buildConfigField 的方式將 encrypt.gradle 裡面的 encryptKey 暴露給 kotlin 使用。

接著就可以在要叫用的地方使用如下:

textView.text = AESUtil.getTextFile(context, R.raw.text)

imageView.setImageDrawable(AESUtil.getDrawable(context, R.drawable.image))

後記

文內有提到,目前的方法還不能解析向量圖檔,原因是 Android 在讀取這類型的圖檔時,只認編譯過的 Android 專用二進制檔案(axml),所以這部分就會變成需要先把我們加密過的 xml 檔案解密出來後,再編譯成符合 axml 格式,也就是 Android 讀得懂的格式,最後就可以透過 VectorDrawable.createFromXml() 之類的方式來把檔案餵給對應的 view。

資料來源
https://github.com/aguai1/AndroidGradleEncrypt