Search code examples
androidapk-expansion-filesandroid-10.0

How can we access an expansion file in Android Q?


I'm writing an app that needs an expansion file and I want to ensure it will be compatible with Android Q. It seems the documentation provided does not address the changes in Android Q. In Android Q, getExternalStorageDirectory() won't be able to be used so how can we access the expansion file?


Solution

  • From the documentation linked to in the question, we know that an expansion file's name has the form:

    [main|patch].<expansion-version>.<package-name>.obb

    and the getObbDir() method returns the specific location for expansion files in the following form:

    <shared-storage>/Android/obb/<package-name>/

    So, the question is how do we access such files?

    To answer this question, I have taken a directory containing five APK files and created an OBB file named "main.314159.com.example.opaquebinaryblob.obb" using JOBB. My intention is to mount and read this OBB file to display the APK file names and the count of entries in each APK (read as Zip files) in a small demo app.

    The demo app will also try to create/read test files in various directories under the external storage directory.

    The following was performed on a Pixel XL emulator running the latest available version of "Q" (Android 10.0 (Google APIs)). The app has the following characterisics:

    • targetSdkVersion 29
    • minSdkVersion 18
    • No explicit permissions specified in the manifest

    I peeked ahead to see what directory getObbDir() returns for this little app and found that it is

    /storage/emulated/0/Android/obb/com.example.opaquebinaryblob

    so I uploaded my OBB file to

    /storage/emulated/0/Android/obb/com.example.opaquebinaryblob/main.314159.com.example.opaquebinaryblob.obb

    using Android Studio. Here is where the file wound up.

    enter image description here

    So, can we mount and read this OBB file? Can we create/read files in other directories within the external files path? Here is what the app reports on API 29:

    enter image description here

    The only files that are accessible reside in /storage/emulated/0/Android/obb/com.example.opaquebinaryblob. Other files in the hierarchy cannot be either created or read. (Interestingly, though, the existence of these files could be determined.)

    For the preceding display, the app opens the OBB file and reads it directly without mounting it.

    When we try to mount the OBB file and dump its contents, this is what is reported:

    enter image description here

    Which is what we expect. In short, it looks like Android Q is restricting access to the external files directory while allowing targeted access based up the package name of the app.

    MainActivity.kt

    class MainActivity : AppCompatActivity() {
        private lateinit var myObbFile: File
        private lateinit var mStorageManager: StorageManager
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
    
            obbDumpText.movementMethod = ScrollingMovementMethod()
    
            val sb = StringBuilder()
    
            val extStorageDir = Environment.getExternalStorageDirectory()
            sb.appendln("getExternalStorageDirectory() reported at $extStorageDir").appendln()
            myObbFile = File(obbDir, BLOB_FILE_NAME)
    
            val obbDir = obbDir
            sb.appendln("obbDir reported at $obbDir").appendln()
            myObbFile = File(obbDir, BLOB_FILE_NAME)
    
            val directoryPathList = listOf(
                "$extStorageDir",
                "$extStorageDir/Pictures",
                "$extStorageDir/Android/obb/com.example.anotherpackage",
                "$extStorageDir/Android/obb/$packageName"
            )
            var e: Exception?
            for (directoryPath in directoryPathList) {
                val fileToCheck = File(directoryPath, TEST_FILE_NAME)
                e = checkFileReadability(fileToCheck)
                if (e == null) {
                    sb.appendln("$fileToCheck is accessible.").appendln()
                } else {
                    sb.appendln(e.message)
                    try {
                        sb.appendln("Trying to create $fileToCheck")
                        fileToCheck.createNewFile()
                        sb.appendln("Created $fileToCheck")
                        e = checkFileReadability(fileToCheck)
                        if (e == null) {
                            sb.appendln("$fileToCheck is accessible").appendln()
                        } else {
                            sb.appendln("e").appendln()
                        }
                    } catch (e: Exception) {
                        sb.appendln("Could not create $fileToCheck").appendln(e).appendln()
                    }
                }
            }
    
            if (!myObbFile.exists()) {
                sb.appendln("OBB file doesn't exist: $myObbFile").appendln()
                obbDumpText.text = sb.toString()
                return
            }
    
            e = checkFileReadability(myObbFile)
            if (e != null) {
                // Need to request READ_EXTERNAL_STORAGE permission before reading OBB file
                sb.appendln("Need READ_EXTERNAL_STORAGE permission.").appendln()
                obbDumpText.text = sb.toString()
                return
            }
    
            sb.appendln("OBB is accessible at")
                .appendln(myObbFile).appendln()
    
            mStorageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
            obbDumpText.text = sb.toString()
        }
    
        private fun dumpMountedObb(obbMountPath: String) {
            val obbFile = File(obbMountPath)
    
            val sb = StringBuilder().appendln("Dumping OBB...").appendln()
            sb.appendln("OBB file path is $myObbFile").appendln()
            sb.appendln("OBB mounted at $obbMountPath").appendln()
            val listFiles = obbFile.listFiles()
            if (listFiles == null || listFiles.isEmpty()) {
                Log.d(TAG, "No files in obb!")
                return
            }
            sb.appendln("Contents of OBB").appendln()
            for (listFile in listFiles) {
                val zipFile = ZipFile(listFile)
                sb.appendln("${listFile.name} has ${zipFile.entries().toList().size} entries.")
                    .appendln()
            }
            obbDumpText.text = sb.toString()
        }
    
        private fun checkFileReadability(file: File): Exception? {
            if (!file.exists()) {
                return IOException("$file does not exist")
            }
    
            var inputStream: FileInputStream? = null
            try {
                inputStream = FileInputStream(file).also { input ->
                    input.read()
                }
            } catch (e: IOException) {
                return e
            } finally {
                inputStream?.close()
            }
            return null
        }
    
        fun onClick(view: View) {
            mStorageManager.mountObb(
                myObbFile.absolutePath,
                null,
                object : OnObbStateChangeListener() {
                    override fun onObbStateChange(path: String, state: Int) {
                        super.onObbStateChange(path, state)
                        val mountPath = mStorageManager.getMountedObbPath(myObbFile.absolutePath)
                        dumpMountedObb(mountPath)
                    }
                }
            )
        }
    
        companion object {
            const val BLOB_FILE_NAME = "main.314159.com.example.opaquebinaryblob.obb"
            const val TEST_FILE_NAME = "TestFile.txt"
            const val TAG = "MainActivity"
        }
    }
    

    activity_main.xml

    <androidx.constraintlayout.widget.ConstraintLayout 
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="16dp"
        tools:context=".MainActivity">
    
        <TextView
            android:id="@+id/obbDumpText"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:scrollbars="vertical"
            android:text="Click the button to view content of the OBB."
            android:textColor="@android:color/black"
            app:layout_constraintBottom_toTopOf="@+id/dumpMountObb"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_chainStyle="spread_inside" />
    
        <Button
            android:id="@+id/dumpMountObb"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="onClick"
            android:text="Dump\nMounted OBB"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/obbDumpText"
            app:layout_constraintVertical_bias="0.79" />
    </androidx.constraintlayout.widget.ConstraintLayout>
    

    For a follow-up as stated here:

    Since Android 4.4 (API level 19), apps can read OBB expansion files without external storage permission. However, some implementations of Android 6.0 (API level 23) and later still require permission, so you will need to declare the READ_EXTERNAL_STORAGE permission in the app manifest and ask for permission at runtime...

    Does this apply to Android Q? It is not clear. The demo shows that it does not for the emulator. I hope that this is something that will be consistent across devices.