Skip to content

Add Android App Bundle (.aab) file support. #68

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/extractcode/androidappbundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# ScanCode is a trademark of nexB Inc.
# SPDX-License-Identifier: Apache-2.0
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
# See https://github.com/nexB/extractcode for support or download.
# See https://aboutcode.org for more information about nexB OSS projects.
#

import logging
import os
import zipfile
import attr

from extractcode import ExtractErrorFailedToExtract

"""
Support to extract Android App Bundle (.aab) files.
"""

logger = logging.getLogger(__name__)

TRACE = False

if TRACE:
import sys
logging.basicConfig(stream=sys.stdout)
logger.setLevel(logging.DEBUG)


@attr.s
class AndroidAppBundle:
location = attr.ib()
extracted_dir = attr.ib(default=None)

@classmethod
def from_file(cls, location):
"""
Build a new AndroidAppBundle from the file at location.
Raise exceptions on errors.
"""
assert location
abs_location = os.path.abspath(os.path.expanduser(location))

if not os.path.exists(abs_location):
raise ExtractErrorFailedToExtract(
f'The system cannot find the path specified: {abs_location}')

if not is_aab(abs_location):
raise ExtractErrorFailedToExtract(
f'Unsupported file format: {abs_location}. Expected an Android App Bundle (.aab).')

return cls(location=abs_location)

def extract(self, target_dir):
"""
Extract the Android App Bundle (.aab) file to the target directory.
Return a dictionary mapping file paths to their sizes.
Raise exceptions on errors.
"""
assert target_dir
abs_target_dir = os.path.abspath(os.path.expanduser(target_dir))

if not os.path.exists(abs_target_dir) or not os.path.isdir(abs_target_dir):
raise ExtractErrorFailedToExtract(
f'The system cannot find the target directory path specified: {target_dir}')

try:
with zipfile.ZipFile(self.location, 'r') as zip_ref:
zip_ref.extractall(abs_target_dir)
self.extracted_dir = abs_target_dir

# Generate a file map of extracted files and their sizes
file_map = {}
for root, _, files in os.walk(abs_target_dir):
for file in files:
file_path = os.path.join(root, file)
file_size = os.path.getsize(file_path)
# Normalize the path to use forward slashes
relative_path = os.path.relpath(file_path, abs_target_dir).replace(os.sep, '/')
file_map[relative_path] = file_size

return file_map
except Exception as e:
raise ExtractErrorFailedToExtract(f'Failed to extract {self.location}: {e}')

def show_file_map(self):
"""
Show the file map of extracted files and their sizes.
"""
if not self.extracted_dir:
raise ExtractErrorFailedToExtract('No files have been extracted yet.')

# Generate the file map dynamically
file_map = {}
for root, _, files in os.walk(self.extracted_dir):
for file in files:
file_path = os.path.join(root, file)
file_size = os.path.getsize(file_path)
relative_path = os.path.relpath(file_path, self.extracted_dir)
# Normalize the path to use forward slashes
relative_path = os.path.relpath(file_path, self.extracted_dir).replace(os.sep, '/')
file_map[relative_path] = file_size

# Print the file map
for file_path, file_size in file_map.items():
print(f'{file_path} ({file_size} bytes)')


def is_aab(file_path):
"""
Check if a file is an Android App Bundle (.aab) by checking its extension.
"""
return file_path.endswith('.aab')


def extract(location, target_dir):
"""
Extract an Android App Bundle (.aab) file at ``location`` to the ``target_dir`` directory.
Return a dictionary mapping file paths to their sizes.
Raise Exception on errors.
"""
assert target_dir
abs_target_dir = os.path.abspath(os.path.expanduser(target_dir))

if not os.path.exists(abs_target_dir) or not os.path.isdir(abs_target_dir):
raise ExtractErrorFailedToExtract(
f'The system cannot find the target directory path specified: {target_dir}')

aab = AndroidAppBundle.from_file(location)
return aab.extract(abs_target_dir)
Binary file added tests/data/androidappbundle/app-release.aab
Binary file not shown.
124 changes: 124 additions & 0 deletions tests/test_androidappbundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# ScanCode is a trademark of nexB Inc.
# SPDX-License-Identifier: Apache-2.0
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
# See https://github.com/nexB/extractcode for support or download.
# See https://aboutcode.org for more information about nexB OSS projects.
#

import os
#from pathlib import Path

import pytest
import io
from contextlib import redirect_stdout

from extractcode_assert_utils import BaseArchiveTestCase
from extractcode_assert_utils import check_files

from extractcode import androidappbundle as aab_extractor


class TestExtractAAB(BaseArchiveTestCase):
test_data_dir = os.path.join(os.path.dirname(__file__), 'data')

def test_can_extract_aab_file(self):
# Path to the test .aab file
test_file = self.get_test_loc('androidappbundle/app-release.aab')
target_dir = self.get_temp_dir('aab_extraction')

# Extract the .aab file and get the file map
file_map = aab_extractor.extract(test_file, target_dir)

# Check if expected files are extracted
expected_files = [
'BUNDLE-METADATA/com.android.tools.build.libraries/dependencies.pb',
'base/manifest/AndroidManifest.xml',
'base/resources.pb',
'BundleConfig.pb',
]
# Verify that all expected files are in the file map
for expected_file in expected_files:
assert expected_file in file_map, f"Expected file {expected_file} not found in the file map"

# Verify that the directories and files are physically created
for expected_file in expected_files:
# Construct the full path to the expected file
full_path = os.path.join(target_dir, expected_file)
# Check if the file exists
assert os.path.exists(full_path), f"Expected file {full_path} does not exist"
# Check if it is a file (not a directory)
assert os.path.isfile(full_path), f"Expected file {full_path} is not a file"

# Verify that the directories are created
expected_directories = [
'BUNDLE-METADATA',
'BUNDLE-METADATA/com.android.tools.build.libraries',
'base',
'base/manifest',
]

for expected_dir in expected_directories:
# Construct the full path to the expected directory
full_path = os.path.join(target_dir, expected_dir)
# Check if the directory exists
assert os.path.exists(full_path), f"Expected directory {full_path} does not exist"
# Check if it is a directory
assert os.path.isdir(full_path), f"Expected directory {full_path} is not a directory"

def test_can_identify_aab_file(self):
# Path to the test .aab file
test_file = self.get_test_loc('androidappbundle/app-release.aab')

# Check if the file is identified as an .aab file
assert aab_extractor.is_aab(test_file) == True

def test_extract_aab_invalid_file(self):
# Create an invalid .aab file (not a zip file)
invalid_file = os.path.join(self.get_temp_dir(), 'invalid.aab')
with open(invalid_file, 'w') as f:
f.write('This is not a valid .aab file')

target_dir = self.get_temp_dir('aab_extraction_invalid')

# Attempt to extract the invalid .aab file
with pytest.raises(Exception):
aab_extractor.extract(invalid_file, target_dir)

def test_extract_aab_nonexistent_file(self):
# Define a non-existent .aab file
nonexistent_file = "nonexistent.aab"
target_dir = self.get_temp_dir('aab_extraction_nonexistent')

# Attempt to extract the non-existent .aab file
with pytest.raises(aab_extractor.ExtractErrorFailedToExtract):
aab_extractor.extract(nonexistent_file, target_dir)

def test_show_file_map(self):
# Path to the test .aab file
test_file = self.get_test_loc('androidappbundle/app-release.aab')
target_dir = self.get_temp_dir('aab_extraction')

# Create an AndroidAppBundle instance and extract the .aab file
aab = aab_extractor.AndroidAppBundle.from_file(test_file)
file_map = aab.extract(target_dir)

# Verify that the file map is not empty
assert file_map, "File map should not be empty after extraction"

# Call show_file_map() and capture the output
output = io.StringIO()
with redirect_stdout(output):
aab.show_file_map()

# Verify that the output contains expected files
output_str = output.getvalue()
expected_files = [
'BUNDLE-METADATA/com.android.tools.build.libraries/dependencies.pb',
'base/manifest/AndroidManifest.xml',
'base/resources.pb',
'BundleConfig.pb',
]
for expected_file in expected_files:
assert expected_file in output_str, f"Expected file {expected_file} not found in the output"
Loading