How to parse an APK after converting it to a minimal one? - android

Background
I want to be able to parse APK files from various sources, or various kinds. I want to get only very specific, basic information about them:
package-name
version-code
version-name
label (app name)
icon
minSdkVersion
whether it's a split APK or not (available when parsing the manifest)
The Android framework has a single function to parse APK files (PackageManager.getPackageArchiveInfo), but it has 2 disadvantages:
It requires a file-path. This isn't always available, as you might have a Uri to deal with, or you have the APK file being inside some ZIP file.
It can't handle split APK files. Only the base one of them. If you try it on split APK files, you just get null.
Because of this, I've tried to find a reliable, Java&Kotlin solution that can handle them easily.
The problem
Each solution I've found has its own disadvatanges. Some libraries I couldn't even find how to use, and some don't have any way to parse APK files without having a real file path. Some of them just create new files instead of returning you objects in Java/Kotlin. Some are even in other programming languages, making me wonder if it's possible to use them.
So far, the only one that I've found as good enough, is "hsiafan" apk-parser, which needs only 2 files within the APK: manifest and resources files. It has some issues, but usually it can get you the information I've mentioned.
Thing is, as I wrote, it's not always working, so I want to return to the basics, at least when I notice it fails to parse, at least for the case that it's a normal APK of base of split-APK files. Some cases it couldn't handle well are:
Sometimes the app name couldn't be fetched correctly (here).
Some resources are somehow hidden in some apps (here). Even tools such as Jadx fail to show them, but others can.
Adaptive icon is hard to parse, including drawable XML files. I succeed parsing VectorDrawable though (here), but it's quite a workaround.
And a few others.
What I've tried
So, I wanted to try out the Android framework again, but this time with a new idea: instead of handling the entire original APK, I could extract only what it really needs, based on the current observation of it.
For example, if I know the resources I need to handle, I can copy only them (and some key files that are needed) into a new APK, and ditch the rest. This could reduce the need to copy/download huge amount of data just to parse the APK and get information about it. If the APK file is 100MB, for example, there is no need to get it all when all we need is just a tiny portion of it.
As a start, I wanted to see how I can create a new APK that can be parsed, so for now I copied all of the entries of the original APK (currently of the current app) into a new file, and chose to parse it:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState != null)
return
thread {
val originalApplicationInfo = packageManager.getApplicationInfo(packageName, 0)
val filePath = originalApplicationInfo.publicSourceDir
val outputFile = File(cacheDir, "test.apk")
outputFile.parentFile!!.mkdirs()
outputFile.delete()
ZipFile(filePath).use { zipFile ->
ZipOutputStream(FileOutputStream(outputFile)).use { zipOutputStream ->
for (entry in zipFile.entries()) {
val name = entry.name
zipOutputStream.putNextEntry(ZipEntry(name))
zipFile.getInputStream(entry).use { it.copyTo(zipOutputStream.buffered()) }
zipOutputStream.closeEntry()
}
}
}
val originalLabel = originalApplicationInfo.loadLabel(packageManager)
val originalIcon: Drawable? = originalApplicationInfo.loadIcon(packageManager)
Log.d("AppLog", "originalPackageInfo: label:$originalLabel appIcon:${originalIcon?.javaClass?.simpleName}")
//
val packageArchiveInfo = packageManager.getPackageArchiveInfo(outputFile.absolutePath, 0)
val label = packageArchiveInfo?.applicationInfo?.loadLabel(packageManager)?.toString()
val appIcon = packageArchiveInfo?.applicationInfo?.loadIcon(packageManager)
Log.d("AppLog", "packageArchiveInfo!=null?${packageArchiveInfo != null} label:$label appIcon:${appIcon?.javaClass?.simpleName}")
}
}
}
The file indeed gets generated, but for some reason the Android framework failed to parse it, as packageArchiveInfo is null.
The questions
How come the sample I've made doesn't work? How come the new APK can't be parsed?
What is the minimal set of files the getPackageArchiveInfo function requires to be able to parse the APK, just for the information I've mentioned above?
If this kind of solution doesn't work, is there perhaps a library that could handle APK files of all kinds with all the information I've mentioned, no matter the source (including Uri and within zip files)?
Edit: as suggested, I could copy just those of folder "AndroidManifest.xml", "resources.arsc", "res" , but it seems it can still not always work well, as the icons don't always get to be the same:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState != null)
return
thread {
val installedApplications = packageManager.getInstalledPackages(0)
Log.d("AppLog", "checking ${installedApplications.size} apps")
for (originalPackageInfo in installedApplications) {
val originalApplicationInfo = originalPackageInfo.applicationInfo
val filePath = originalApplicationInfo.publicSourceDir
val outputFile = File(cacheDir, "test.apk")
outputFile.parentFile!!.mkdirs()
outputFile.delete()
val toExtract = setOf<String>("AndroidManifest.xml", "resources.arsc", "res")
ZipFile(filePath).use { zipFile ->
ZipOutputStream(FileOutputStream(outputFile)).use { zipOutputStream ->
for (entry in zipFile.entries()) {
val name = entry.name
if (toExtract.contains(name.split("/")[0])) {
zipOutputStream.putNextEntry(ZipEntry(name))
zipFile.getInputStream(entry).use { inStream ->
zipOutputStream.buffered().apply {
inStream.copyTo(this)
}.flush()
}
}
}
}
}
val packageName = originalApplicationInfo.packageName
val originalLabel = originalApplicationInfo.loadLabel(packageManager)
val originalIcon: Drawable? = originalApplicationInfo.loadIcon(packageManager)
val originalIconBitmap = originalIcon?.toBitmap()
//
val packageArchiveInfo = packageManager.getPackageArchiveInfo(outputFile.absolutePath, 0)
if (packageArchiveInfo == null) {
Log.e("AppLog", "$packageName could not parse generated APK")
continue
}
val label = packageArchiveInfo.applicationInfo.loadLabel(packageManager).toString()
val appIcon = packageArchiveInfo?.applicationInfo?.loadIcon(packageManager)
val appIconBitmap = appIcon?.toBitmap()
when {
label != originalLabel ->
Log.e("AppLog", "$packageName got wrong label $label vs $originalLabel")
packageArchiveInfo.versionName != originalPackageInfo.versionName ->
Log.e("AppLog", "$packageName got wrong versionName ${packageArchiveInfo.versionName} vs ${originalPackageInfo.versionName}")
packageArchiveInfo.versionCode != originalPackageInfo.versionCode ->
Log.e("AppLog", "$packageName got wrong versionCode ${packageArchiveInfo.versionCode} vs ${originalPackageInfo.versionCode}")
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && packageArchiveInfo.applicationInfo.minSdkVersion != originalApplicationInfo.minSdkVersion ->
Log.e("AppLog", "$packageName got wrong minSdkVersion ${packageArchiveInfo.applicationInfo.minSdkVersion} vs ${originalApplicationInfo.minSdkVersion}")
appIcon?.javaClass?.name != originalIcon?.javaClass?.name ->
Log.e("AppLog", "$packageName got different app icon type: ${appIcon?.javaClass?.simpleName} vs ${originalIcon?.javaClass?.simpleName}")
originalIconBitmap != null && appIconBitmap != null && (originalIconBitmap.width != appIconBitmap.width || originalIconBitmap.height != appIconBitmap.height) ->
Log.e("AppLog", "$packageName got wrong app icons sizes:${appIconBitmap.width}x${appIconBitmap.height} vs ${originalIconBitmap.width}x${originalIconBitmap.height}")
originalIconBitmap != null && appIconBitmap != null && !areBitmapsSame(originalIconBitmap, appIconBitmap) ->
Log.e("AppLog", "$packageName got wrong app icons content ")
(originalIconBitmap == null && appIconBitmap != null) || (originalIconBitmap != null && appIconBitmap == null) ->
Log.e("AppLog", "$packageName null vs non-null app icon: ${appIconBitmap != null} vs ${originalIconBitmap != null}")
}
}
Log.d("AppLog", "done")
}
}
fun areBitmapsSame(bitmap: Bitmap, bitmap2: Bitmap): Boolean {
if (bitmap.width != bitmap2.width || bitmap.height != bitmap2.height)
return false
for (x in 0 until bitmap.width)
for (y in 0 until bitmap.height)
if (bitmap.getPixel(x, y) != bitmap2.getPixel(x, y))
return false
return true
}
}
I think that since app icons are very complex and depend on various resources (which might even be hidden in weird ways), there is no other choice than to actually have the file on the file system.
EDIT: about getting the app icons, I actually didn't use it well for the APK file. I used something a bit different on my own app, which seems to work well here too, except for some cases that the icon is a bit different (shape/color might be different for example), probably due to different configurations. But at least it won't return you the default app icon of Android when the app clearly has a non-default app icon.
Sadly though, it didn't always get good app icon when creating the minimized APKs (sometimes returned me ColorStateListDrawable or ColorDrawable, for example), probably because sometimes the original APK has hidden resources in non-conventional paths. So this is how you can get the app icon, assuming you have the whole APK:
Before getting it, use:
packageArchiveInfo.applicationInfo.publicSourceDir = targetFilePath
packageArchiveInfo.applicationInfo.sourceDir = targetFilePath
And then call this function:
fun getAppIcon(context: Context, applicationInfo: ApplicationInfo): Drawable? {
val packageManager = context.packageManager
try {
val iconResId = applicationInfo.icon
if (iconResId != 0) {
val resources: Resources = packageManager.getResourcesForApplication(applicationInfo)
val density = context.resources.displayMetrics.densityDpi
var result = ResourcesCompat.getDrawableForDensity(resources, iconResId, density, null)
if (result != null)
return result
}
} catch (e: Exception) {
// e.printStackTrace()
}
try {
val applicationIcon = packageManager.getApplicationIcon(applicationInfo)
// Log.d("AppLog", "getApplicationIcon type:${applicationIcon.javaClass.simpleName}")
return applicationIcon
} catch (ignored: Exception) {
}
return null
}
To convert to Bitmap, you can use:
val appIconBitmap = try {
appIcon?.toBitmap(appIconSize, appIconSize)
} catch (e: Exception) {
e.printStackTrace()
null
}
And to get the app icon size, you can use:
fun getAppIconSize(context: Context): Int {
val activityManager = context.getSystemService<ActivityManager>()
val appIconSize = try {
activityManager.launcherLargeIconSize
} catch (e: Exception) {
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48f, context.resources.displayMetrics).toInt()
}
return appIconSize
}

Change the copy code from
zipFile.getInputStream(entry).use { it.copyTo(zipOutputStream.buffered()) }
to
zipFile.getInputStream(entry).use {
val bufStream = zipOutputStream.buffered()
it.copyTo(bufStream)
bufStream.flush()
}
or something similar to make sure that all the data gets written out.
I thought that the APK was failing certificate verification, but that is not the case. The foregoing will get you the label and the icon.
With the original code I was seeing the following error
chunk size is bigger than given data
Failed to load 'resources.arsc' in APK '/data/user/0/com
which indicates to me that something went awry with the output. With the aforementioned change, I see the following:
originalPackageInfo: label:APK Copy and Read appIcon:AdaptiveIconDrawable
packageArchiveInfo!=null?true label:APK Copy and Read appIcon:AdaptiveIconDrawable
If I open the created APK from the Device Explorer in Android Studio, it parses out nicely.
As for the minimum, I think, for your purposes, you will need the manifest, the resource.arsc file and the res directory. Here is how to get a reduced APK that will parse with just these elements:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState != null)
return
thread {
val originalApplicationInfo = packageManager.getApplicationInfo(packageName, 0)
val filePath = originalApplicationInfo.publicSourceDir
val outputFile = File(cacheDir, "test.apk")
outputFile.parentFile!!.mkdirs()
outputFile.delete()
val toExtract = setOf<String>("AndroidManifest.xml", "resources.arsc","res")
ZipFile(filePath).use { zipFile ->
ZipOutputStream(FileOutputStream(outputFile)).use { zipOutputStream ->
for (entry in zipFile.entries()) {
val name = entry.name
if (toExtract.contains(name.split("/")[0])) {
zipOutputStream.putNextEntry(ZipEntry(name))
zipFile.getInputStream(entry).use { inStream ->
zipOutputStream.buffered().apply {
inStream.copyTo(this)
}.flush()
}
}
}
}
}
val originalLabel = originalApplicationInfo.loadLabel(packageManager)
val originalIcon: Drawable? = originalApplicationInfo.loadIcon(packageManager)
Log.d(
"AppLog",
"originalPackageInfo: label:$originalLabel appIcon:${originalIcon?.javaClass?.simpleName}"
)
//
val packageArchiveInfo =
packageManager.getPackageArchiveInfo(outputFile.absolutePath, 0)
val label = packageArchiveInfo?.applicationInfo?.loadLabel(packageManager)?.toString()
val appIcon = packageArchiveInfo?.applicationInfo?.loadIcon(packageManager)
Log.d(
"AppLog",
"packageArchiveInfo!=null?${packageArchiveInfo != null} label:$label appIcon:${appIcon?.javaClass?.simpleName}"
)
}
}
}

Related

Get all images metadata from Input String Android

I have a resources folder where I added some images that I am using for unit testing. I am able to locate the path of the resources folder via InputStream. However, I need to access these images somehow and get their metadata. Is there a way I can run a filter loop inside the resources folder to get all images?
fun readImagesMetadata(inputStream: InputStream): Array<ImageMetadata>{
println("Reading images")
try {
val builder = StringBuilder()
val reader = BufferedReader(InputStreamReader(inputStream))
var str: String? = reader.readLine()
while (str != null) {
builder.append(str)
str = reader.readLine()
print(str)
}
return emptyArray()
} finally {
inputStream.close()
}
}

How can I read json file in local test on Android Studio?

I am testing with MockWebServer.
And I need a lot of json files for request and response data.
Hard coded json values seem messy and I want to create json files instead.
So, I created json files in resources(test). And I tried to read file with these methods.
object TestHelper {
fun read(fileName: String): String {
val resource = javaClass.classLoader?.getResource(fileName)
return resource?.readText() ?: ""
}
fun readJson(fileName: String): String {
val byteArray = readBinaryFileFromResources(fileName)
val sb = StringBuilder("")
byteArray.forEach {
println("byte: $it")
sb.append(it as Char)
}
return sb.toString()
}
#Throws(IOException::class)
fun readBinaryFileFromResources(fileName: String): ByteArray {
var inputStream: InputStream? = null
val byteStream = ByteArrayOutputStream()
try {
inputStream = javaClass.classLoader?.getResourceAsStream(fileName)
var nextValue = inputStream?.read() ?: -1
while (nextValue != -1) {
byteStream.write(nextValue)
nextValue = inputStream?.read() ?: -1
}
return byteStream.toByteArray()
} catch (e: Exception) {
println(e.stackTraceToString())
return byteStream.toByteArray()
} finally {
inputStream?.close()
byteStream.close()
}
}
}
None of them seems work. What's the problem with this code?
I've had trouble with this before, and I believe it has to do with getting the correct classLoader from the call site, as well as having resources in the src/test/resources not being accessible properly. I eventually got it to work by passing in the calling test class as a reified type parameter:
inline fun <reified T> loadFileText(
caller: T,
filePath: String
): String =
T::class.java.getResource(filePath)?.readText() ?: throw IllegalArgumentException(
"Could not find file $filePath. Make sure to put it in the correct resources folder for $caller's runtime."
)
For my setup I have a separate shared module :testtools that I use testImplementation to include in my :app's gradle build so they don't get compiled into the production APK. I have my test resources in:
/testtools/src/main/resources/customfolder
And then calling this from a unit test class in :app like so:
class UnitTestClass {
#Test
fun myTest() {
loadFileText(this, "/customfolder/file_name.txt")
}
}
You might have some luck putting your resources straight into /app/src/test/resources/customfolder, I haven't tried in a while.

Getting all Images in Android

I am building a photo vault where users can hide their photos.
I write the following code which traverses through all the directories (except the hidden one) and creates a report mentioning the number of images with directory name and file(images) path.
It works fine and does its job but the problem here is the amount of time it takes to execute.
I run it on my OnePlus 7T with 128 GB Storage and 52% used it takes almost 30-40 seconds to get all the images. That is an insane amount of time for a user to wait every time they want to add an image to the vault.
I want to know what optimization could be made here so that its speed gets optimized. I have tested other similar applications and they are doing it in a snap.
Please let me know if you can help.
#OptIn(ExperimentalTime::class)
private fun getFiles(): List<MyFileModel> {
val list = mutableListOf<MyFileModel>()
val time = measureTime {
Environment.getExternalStorageDirectory().listFiles()?.forEach {file->
if (file.isDirectory) {
openDirectory(file)
} else if (file.isImage) {
addImage(file)
Log.i(
TAG,
"getFiles: image: ${file.name}\nParent File: ${file.parentFile}\nParent: ${file.parent}"
)
}
}
}
Log.i(
TAG,
"getFiles: took ${time.inWholeHours}h : ${time.inWholeMinutes}m : ${time.inWholeSeconds}s"
)
map.keys.forEach {
Log.i(TAG, "getFiles: There are ${map[it]?.size} images in $it directory")
}
return listOf()
}
private fun addImage(file: File) {
val parentPath = file.parent ?: throw Exception("Could not add file as image. File: $file")
var folderName: String? = null
if (parentPath == FileUtils.ROOT_ADDRESS.path) {
folderName = "STORAGE"
//File is in the home directory
} else {
folderName = parentPath.substring(parentPath.lastIndexOf("/") + 1)
}
val files: MutableList<File>? = map[folderName]
if (files.isNullOrEmpty()) {
map[folderName] = mutableListOf(file)
} else {
files.addIfNotAlreadyAdded(file)
}
// Log.i(TAG, "addImage: map: $map")
}
//
private fun openDirectory(file: File) {
Log.i(TAG, "getFiles: FILE: ${file.absolutePath}")
if (file.isHidden) return
if (file.isImage) {
addImage(file)
return
}
if (file.isDirectory) {
file.listFiles()?.forEach {
Log.i(TAG, "openDirectory: file.listFiles().forEach : file: $it")
if (it.isImage) {
addImage(it)
}
if (it.isDirectory) {
openDirectory(it)
}
}
}
}
Here is the extensions function that checks if the file is an image or not.
val File.isImage: Boolean
get() {
val fileName = this.name
val lasIndexOfDot = fileName.lastIndexOf(".")
if (lasIndexOfDot == -1) {
//This means that the file got no extension
return false
}
val extension = fileName.substring(fileName.lastIndexOf(".") + 1).lowercase()
return extension.equals("png") ||
extension.equals("jpeg") ||
extension.equals("jpg") ||
extension.equals("gif")
}
Thank you :)
Finally, I was able to do that by implementing the Content Provider.
Going through all files and folders in the storage and then checking each file if it is an image or not and that too by looking at the file extension. It was a set of terrible ideas.
But in the end, this is how we learn. :)

How to get file path and file instance from a SAF file picker request?

I am using the following function for making request and decoding URI from onActivity result . The following function works but freezes the whole screen for many seconds before generating the final file:
// Request code:
fun filePickerRequest3SAF(activity: AppCompatActivity) {
RequestIntentBuilder(IntentInit.OPEN_DOCUMENT) // Intent(Intent.ACTION_OPEN_DOCUMENT)
.addOpenableCategory()//requestIntent.addCategory(Intent.CATEGORY_OPENABLE)
.setFilteringMimeType("video/*")
.addFlagForReadPermission() //requestIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) and requestIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.buildAndStartActivityForResult(activity)
}
//response function. here I pass received intent's URI and activity context
fun getPathFromUriOrEmpty(uri: Uri?, context: Context?): String {
if (context == null || uri == null) return ""
return getFileFromUriOrDefault(uri, context)?.path ?: ""
}
fun getFileFromUriOrDefault(uri: Uri?, context: Context?, default:File? = null): File? {
if (context == null || uri == null) return default
val resolver = context.contentResolver
val tmpFile = File(context.cacheDir, getFileNameFromUriOrNull(uri, resolver) ?: "temp_file_name.${getExtensionFromUriOrDefault(uri, context)}")
return try {
val inputStream = context.contentResolver.openInputStream(uri)
val outputStream = FileOutputStream(tmpFile)
outputStream.use { fileOut -> inputStream?.copyTo(fileOut) }
tmpFile
} catch (t: Throwable) {
t.printStackTrace()
default
}
}
is there a way to do it better, apart from just making a file and copying it as whole? My app is supposed to upload videos of size > 1-2 gb, so is there a way we can provide the URI / file to the upload service without actually making a copy of file? I am assuming the file upload service will also be making multiple copies to upload
Ps: I intent to support android versions KitKat to android 12/+ , so not thinking of using legacy storage or other such flags if they can be avoided with SAF as a unified solution across different android versions

How to get&modify metadata to supported audio files on Android?

Background
Android supports various audio files encoding and decoding.
I record audio into an audio file using android.media.MediaRecorder class, but I also wish to show information about the files I've recorded (not standard data, but still just text, maybe even configurable by user), and I think it's best to store this information within the files.
examples of possible data to store: when it was recorded, where it was recorded, notes by the user...
The problem
The MediaRecorder class doesn't have any function that I can find, to add or even read metadata of the recorded audio file.
I also can't find a similar class that does it.
What I've tried
I tried searching how to do it for specific files types, and also tried to find a library that does it.
I haven't find even a clue about this information.
The only thing I've found for MediaRecorder class, is a function called "setLocation" , which is used to indicate where the recording has started (geographically), and looking at its code, I can see it sets parameters:
public void setLocation(float latitude, float longitude) {
int latitudex10000 = (int) (latitude * 10000 + 0.5);
int longitudex10000 = (int) (longitude * 10000 + 0.5);
if (latitudex10000 > 900000 || latitudex10000 < -900000) {
String msg = "Latitude: " + latitude + " out of range.";
throw new IllegalArgumentException(msg);
}
if (longitudex10000 > 1800000 || longitudex10000 < -1800000) {
String msg = "Longitude: " + longitude + " out of range";
throw new IllegalArgumentException(msg);
}
setParameter("param-geotag-latitude=" + latitudex10000);
setParameter("param-geotag-longitude=" + longitudex10000);
}
But setParameter is private, and I'm not sure if it's ok to put anything I want into it, even if I had a way to access it (reflection, for example) :
private native void setParameter(String nameValuePair);
I also don't get, given an audio/video file, how to get/modify this kind of information. It's not available for SimpleExoPlayer, for example.
The questions
How can I read,write, and modify metadata inside supported audio files of Android?
Are there any limitations/restrictions for those actions?
Which file formats are available for this?
Is it possible to add the metadata while I record the audio?
Is it possible perhaps via MediaStore ? But then how do I do those operations? And which files are supported? And does the metadata stay within the file?
EDIT: ok I've looked at the solution offered to me (here, repo here, based on here) , and it seems to work well. However, it doesn't work on latest version of the library that it uses (org.mp4parser.isoparser:1.9.37 dependency of mp4parser) , so I leave this question to be answered : Why doesn't it work on latest version of this library?
Code:
object MediaMetaDataUtil {
interface PrepareBoxListener {
fun prepareBox(existingBox: Box?): Box
}
#WorkerThread
fun <T : Box> readMetadata(mediaFilePath: String, boxType: String): T? {
return try {
val isoFile = IsoFile(FileDataSourceImpl(FileInputStream(mediaFilePath).channel))
val nam = Path.getPath<T>(isoFile, "/moov[0]/udta[0]/meta[0]/ilst/$boxType")
isoFile.close()
nam
} catch (e: Exception) {
null
}
}
/**
* #param boxType the type of the box. Example is "©nam" (AppleNameBox.TYPE). More available here: https://kdenlive.org/en/project/adding-meta-data-to-mp4-video/
* #param listener used to prepare the existing or new box
* */
#WorkerThread
#Throws(IOException::class)
fun writeMetadata(mediaFilePath: String, boxType: String, listener: PrepareBoxListener) {
val videoFile = File(mediaFilePath)
if (!videoFile.exists()) {
throw FileNotFoundException("File $mediaFilePath not exists")
}
if (!videoFile.canWrite()) {
throw IllegalStateException("No write permissions to file $mediaFilePath")
}
val isoFile = IsoFile(mediaFilePath)
val moov = isoFile.getBoxes<MovieBox>(MovieBox::class.java)[0]
var freeBox = findFreeBox(moov)
val correctOffset = needsOffsetCorrection(isoFile)
val sizeBefore = moov.size
var offset: Long = 0
for (box in isoFile.boxes) {
if ("moov" == box.type) {
break
}
offset += box.size
}
// Create structure or just navigate to Apple List Box.
var userDataBox: UserDataBox? = Path.getPath(moov, "udta")
if (userDataBox == null) {
userDataBox = UserDataBox()
moov.addBox(userDataBox)
}
var metaBox: MetaBox? = Path.getPath(userDataBox, "meta")
if (metaBox == null) {
metaBox = MetaBox()
val hdlr = HandlerBox()
hdlr.handlerType = "mdir"
metaBox.addBox(hdlr)
userDataBox.addBox(metaBox)
}
var ilst: AppleItemListBox? = Path.getPath(metaBox, "ilst")
if (ilst == null) {
ilst = AppleItemListBox()
metaBox.addBox(ilst)
}
if (freeBox == null) {
freeBox = FreeBox(128 * 1024)
metaBox.addBox(freeBox)
}
// Got Apple List Box
var nam: Box? = Path.getPath(ilst, boxType)
nam = listener.prepareBox(nam)
ilst.addBox(nam)
var sizeAfter = moov.size
var diff = sizeAfter - sizeBefore
// This is the difference of before/after
// can we compensate by resizing a Free Box we have found?
if (freeBox.data.limit() > diff) {
// either shrink or grow!
freeBox.data = ByteBuffer.allocate((freeBox.data.limit() - diff).toInt())
sizeAfter = moov.size
diff = sizeAfter - sizeBefore
}
if (correctOffset && diff != 0L) {
correctChunkOffsets(moov, diff)
}
val baos = BetterByteArrayOutputStream()
moov.getBox(Channels.newChannel(baos))
isoFile.close()
val fc: FileChannel = if (diff != 0L) {
// this is not good: We have to insert bytes in the middle of the file
// and this costs time as it requires re-writing most of the file's data
splitFileAndInsert(videoFile, offset, sizeAfter - sizeBefore)
} else {
// simple overwrite of something with the file
RandomAccessFile(videoFile, "rw").channel
}
fc.position(offset)
fc.write(ByteBuffer.wrap(baos.buffer, 0, baos.size()))
fc.close()
}
#WorkerThread
#Throws(IOException::class)
fun splitFileAndInsert(f: File, pos: Long, length: Long): FileChannel {
val read = RandomAccessFile(f, "r").channel
val tmp = File.createTempFile("ChangeMetaData", "splitFileAndInsert")
val tmpWrite = RandomAccessFile(tmp, "rw").channel
read.position(pos)
tmpWrite.transferFrom(read, 0, read.size() - pos)
read.close()
val write = RandomAccessFile(f, "rw").channel
write.position(pos + length)
tmpWrite.position(0)
var transferred: Long = 0
while (true) {
transferred += tmpWrite.transferTo(0, tmpWrite.size() - transferred, write)
if (transferred == tmpWrite.size())
break
//System.out.println(transferred);
}
//System.out.println(transferred);
tmpWrite.close()
tmp.delete()
return write
}
#WorkerThread
private fun needsOffsetCorrection(isoFile: IsoFile): Boolean {
if (Path.getPath<Box>(isoFile, "moov[0]/mvex[0]") != null) {
// Fragmented files don't need a correction
return false
} else {
// no correction needed if mdat is before moov as insert into moov want change the offsets of mdat
for (box in isoFile.boxes) {
if ("moov" == box.type) {
return true
}
if ("mdat" == box.type) {
return false
}
}
throw RuntimeException("I need moov or mdat. Otherwise all this doesn't make sense")
}
}
#WorkerThread
private fun findFreeBox(c: Container): FreeBox? {
for (box in c.boxes) {
// System.err.println(box.type)
if (box is FreeBox)
return box
if (box is Container) {
val freeBox = findFreeBox(box as Container)
if (freeBox != null) {
return freeBox
}
}
}
return null
}
#WorkerThread
private fun correctChunkOffsets(movieBox: MovieBox, correction: Long) {
var chunkOffsetBoxes = Path.getPaths<ChunkOffsetBox>(movieBox as Box, "trak/mdia[0]/minf[0]/stbl[0]/stco[0]")
if (chunkOffsetBoxes.isEmpty())
chunkOffsetBoxes = Path.getPaths(movieBox as Box, "trak/mdia[0]/minf[0]/stbl[0]/st64[0]")
for (chunkOffsetBox in chunkOffsetBoxes) {
val cOffsets = chunkOffsetBox.chunkOffsets
for (i in cOffsets.indices)
cOffsets[i] += correction
}
}
private class BetterByteArrayOutputStream : ByteArrayOutputStream() {
val buffer: ByteArray
get() = buf
}
}
Sample usage for writing&reading title:
object MediaMetaData {
#JvmStatic
#Throws(IOException::class)
fun writeTitle(mediaFilePath: String, title: String) {
MediaMetaDataUtil.writeMetadata(mediaFilePath, AppleNameBox.TYPE, object : MediaMetaDataUtil.PrepareBoxListener {
override fun prepareBox(existingBox: Box?): Box {
var nam: AppleNameBox? = existingBox as AppleNameBox?
if (nam == null)
nam = AppleNameBox()
nam.dataCountry = 0
nam.dataLanguage = 0
nam.value = title
return nam
}
})
}
#JvmStatic
fun readTitle(mediaFilePath: String): String? {
return MediaMetaDataUtil.readMetadata<AppleNameBox>(mediaFilePath, AppleNameBox.TYPE)?.value
}
}
It seems there's no way to do it uniformly for all supported audio formats in Android. There are some limited options for particular formats though, so I suggest to stick with one format.
MP3 is the most popular one and there should be a lot of options like this one.
If you don't want to deal with encoding/decoding, there are some options for a WAV format.
There's also a way to add a metadata track to a MP4 container using MediaMuxer (you can have an audio-only MP4 file) or like this.
Regarding MediaStore: here's an example (at the end of page 318) on how to add metadata to it just after using MediaRecorder. Though as far as I know the data won't be recorded inside the file.
Update
I compiled an example app using this MP4 parser library and MediaRecorder example from SDK docs. It records an audio, puts it in MP4 container and adds String metadata like this:
MetaDataInsert cmd = new MetaDataInsert();
cmd.writeRandomMetadata(fileName, "lore ipsum tralalala");
Then on the next app launch this metadata is read and displayed:
MetaDataRead cmd = new MetaDataRead();
String text = cmd.read(fileName);
tv.setText(text);
Update #2
Regarding m4a file extension: m4a is just an alias for an mp4 file with AAC audio and has the same file format. So you can use my above example app and just change the file name from audiorecordtest.mp4 to audiorecordtest.m4a and change audio encoder from MediaRecorder.AudioEncoder.AMR_NB to MediaRecorder.AudioEncoder.AAC.

Categories

Resources