Skip to content

Commit fbc4792

Browse files
committed
Refactor main script and update unit tests
List of changes: * Renamed functions and variables for improved clarity and consistency. * Fixed a bug where the script saved the boot image with the active slot suffix even if the user selected slot 'b'. * Enhanced error handling and user feedback with ANSI escape codes. * Improved partition detection logic and introduced delays to improve UX. * Expanded unit tests and adapted script modifications. Signed-off-by: Abhijeet <[email protected]>
1 parent 43d44c3 commit fbc4792

File tree

4 files changed

+117
-76
lines changed

4 files changed

+117
-76
lines changed

scripts/boot_image_extractor.py

+45-44
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,80 @@
11
#!/usr/bin/env python3
22

3-
"""A script to extract boot image from either single or dual slotted Android devices with root access."""
3+
"""A script to extract boot images from any Android device with root access."""
44

55
import os
66
import sys
7+
import time
78
import pyfiglet
89
import subprocess
910

10-
def print_banner(name):
11+
def print_banner(title):
1112
max_width = os.get_terminal_size().columns
12-
banner = pyfiglet.figlet_format(name, font='small', width=max_width)
13+
banner = pyfiglet.figlet_format(title, font='small', width=max_width)
1314
print(banner.center(max_width))
14-
15-
def exit_with_error(error, reason):
16-
print("\nError:", error)
17-
print("\nReason:", reason)
15+
16+
def exit_with_error(error_message, error_detail):
17+
print("\033[91m\nError:\033[0m", error_message)
18+
print("\nReason:", error_detail)
1819
sys.exit(1)
19-
20-
def extract_boot_image_dual_slot(boot_a_path, boot_b_path):
21-
active_slot = subprocess.getoutput('getprop ro.boot.slot_suffix')
22-
print("\nIt is recommended to extract the boot image according to the current active slot, which is ({}).\n".format(active_slot))
20+
21+
def extract_boot_image_for_ab_device(boot_a_path, boot_b_path):
22+
active_slot_suffix = subprocess.getoutput('getprop ro.boot.slot_suffix')
23+
print(f"\n- Current active slot: ({active_slot_suffix}).")
24+
time.sleep(1)
2325

2426
while True:
25-
chosen_slot = input("Which boot slot image would you like to extract? (a/b): ").lower()
26-
if chosen_slot == 'a':
27+
selected_slot = input("- Which boot slot image would you like to extract? (a/b): ").lower()
28+
if selected_slot == 'a':
2729
boot_image_path = boot_a_path
2830
break
29-
elif chosen_slot == 'b':
31+
elif selected_slot == 'b':
3032
boot_image_path = boot_b_path
3133
break
3234
else:
33-
print("Invalid input. Please choose either 'a' or 'b'.\n")
34-
continue
35+
print("Invalid input. Please enter 'a' or 'b'.\n")
3536

36-
print("\nExtracting the boot image from {}...".format(boot_image_path))
37+
print(f"\n- Extracting the boot image from {boot_image_path}...")
38+
time.sleep(1)
3739
try:
38-
subprocess.check_call(['dd', 'if={}'.format(boot_image_path), 'of=./boot{}.img'.format(active_slot)])
39-
print("Boot image successfully extracted and saved in your {} directory.".format(os.path.basename(os.getcwd())))
40+
subprocess.check_call(['dd', f'if={boot_image_path}', f'of=./boot_{selected_slot}.img'])
41+
print(f"\033[92m\n- Boot image successfully extracted and saved as boot_{selected_slot}.img in the current directory.\033[0m")
4042
except subprocess.CalledProcessError:
41-
exit_with_error("Failed to extract the boot image", "dd command failed")
43+
exit_with_error("Extraction failed", "The dd command did not complete successfully.")
4244

43-
def extract_boot_image_single_slot(boot_path):
44-
print("\nExtracting the boot image from {}...".format(boot_path))
45+
def extract_boot_image_for_legacy_device(boot_path):
46+
print(f"\n- Extracting the boot image from {boot_path}...")
47+
time.sleep(1)
4548
try:
46-
subprocess.check_call(['dd', 'if={}'.format(boot_path), 'of=./boot.img'])
47-
print("Boot image successfully extracted and saved in your {} directory.".format(os.path.basename(os.getcwd())))
49+
subprocess.check_call(['dd', f'if={boot_path}', 'of=./boot.img'])
50+
time.sleep(1)
51+
print("\033[92m\n- Boot image successfully extracted and saved as boot.img in the current directory.\033[0m")
4852
except subprocess.CalledProcessError:
49-
exit_with_error("Failed to extract the boot image", "dd command failed")
53+
exit_with_error("Extraction failed", "The dd command did not complete successfully.")
5054

5155
def main():
5256
if os.geteuid() != 0:
5357
exit_with_error("Insufficient privileges", "This script requires root access. Please run as root or use sudo.")
5458

5559
print_banner("Boot Image Extractor")
60+
time.sleep(1)
5661

57-
boot_names = ['boot', 'boot_a', 'boot_b']
58-
for name in boot_names:
59-
path = subprocess.getoutput('find /dev/block -type l -name {} -print | head -n 1'.format(name))
60-
if path:
61-
print("{} = {}".format(name, path))
62-
if name == 'boot_a':
63-
boot_a_path = path
64-
elif name == 'boot_b':
65-
boot_b_path = path
66-
else:
67-
boot_path = path
68-
69-
if 'boot_a_path' in locals() and 'boot_b_path' in locals():
70-
print("\nDevice has dual boot slots.")
71-
extract_boot_image_dual_slot(boot_a_path, boot_b_path)
72-
elif 'boot_path' in locals():
73-
print("\nDevice has a single boot slot.")
74-
extract_boot_image_single_slot(boot_path)
62+
boot_partitions = {}
63+
for partition in ["boot", "boot_a", "boot_b"]:
64+
partition_path = subprocess.getoutput(f"find /dev/block -type b -o -type l -iname '{partition}' -print -quit 2>/dev/null")
65+
if partition_path:
66+
boot_partitions[partition] = os.path.realpath(partition_path)
67+
68+
if 'boot_a' in boot_partitions and 'boot_b' in boot_partitions:
69+
print("\n- A/B partition style detected!.")
70+
time.sleep(1)
71+
extract_boot_image_for_ab_device(boot_partitions['boot_a'], boot_partitions['boot_b'])
72+
elif 'boot' in boot_partitions:
73+
print("\n- Legacy(non-A/B) partition style detected!.")
74+
time.sleep(1)
75+
extract_boot_image_for_legacy_device(boot_partitions['boot'])
7576
else:
76-
exit_with_error("No boot slots found", "unable to find the symlinked boot slot files")
77+
exit_with_error("No boot partition found", "Unable to locate block device files.")
7778

7879
if __name__ == '__main__':
7980
main()

setup.py

+8
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,16 @@
99

1010
setup(
1111
name='Boot-Image-Extractor',
12+
description='A tool to extract boot images from Android devices with root access.',
13+
author='Abhijeet',
14+
url='https://github.com/gitclone-url/Boot-image-Extractor',
1215
scripts=['scripts/boot_image_extractor.py'],
1316
install_requires=[
1417
'pyfiglet',
1518
],
19+
classifiers=[
20+
'Programming Language :: Python :: 3',
21+
'License :: OSI Approved :: MIT License',
22+
'Operating System :: Android',
23+
],
1624
)

tests/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import os
2+
import sys
3+
4+
# Add the parent directory to the system path
5+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

tests/test_boot_image_extractor.py

+59-32
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,75 @@
11
import unittest
2-
import os
3-
import sys
42
from unittest.mock import patch, MagicMock
53

6-
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
7-
4+
from scripts.boot_image_extractor import (
5+
print_banner, exit_with_error, extract_boot_image_for_legacy_device,
6+
extract_boot_image_for_ab_device, main
7+
)
88

99
class TestBootImageExtractor(unittest.TestCase):
10-
"""
11-
Provides unit tests for the Boot Image Extractor script to ensure its proper functionality
12-
in various scenarios. Additional test cases can be added to improve test coverage.
13-
"""
14-
15-
def test_print_banner(self):
16-
from scripts.boot_image_extractor import print_banner
17-
with patch('builtins.print') as mock_print:
18-
print_banner("Test Banner")
19-
mock_print.assert_called_once()
20-
10+
"""Provides unit tests for the Boot Image Extractor script to ensure its proper functionality
11+
in various scenarios. Additional test cases can be added to improve test coverage."""
12+
13+
def infinite_side_effect(self, *values):
14+
while True:
15+
for value in values:
16+
yield value
17+
18+
@patch('builtins.print')
19+
def test_print_banner(self, mock_print):
20+
print_banner("Test Banner")
21+
mock_print.assert_called()
22+
2123
@patch('builtins.print')
2224
@patch('subprocess.getoutput')
2325
@patch('os.geteuid', MagicMock(return_value=0))
24-
def test_extract_boot_image_single_slot(self, mock_getoutput, mock_print):
26+
def test_extract_boot_image_for_legacy_device(self, mock_getoutput, mock_print):
2527
mock_getoutput.return_value = '/dev/block/boot'
2628
with patch('subprocess.check_call', MagicMock()) as mock_check_call:
27-
with patch('os.path.basename', MagicMock(return_value='test')):
28-
with patch('subprocess.getoutput', MagicMock(return_value='a')):
29-
from scripts.boot_image_extractor import extract_boot_image_single_slot
30-
extract_boot_image_single_slot('boot_path')
31-
mock_check_call.assert_called_with(['dd', 'if=boot_path', 'of=./boot.img'])
32-
29+
extract_boot_image_for_legacy_device('/dev/block/boot')
30+
mock_check_call.assert_called_with(['dd', 'if=/dev/block/boot', 'of=./boot.img'])
31+
3332
@patch('builtins.print')
3433
@patch('subprocess.getoutput')
3534
@patch('os.geteuid', MagicMock(return_value=0))
36-
def test_extract_boot_image_dual_slot(self, mock_getoutput, mock_print):
37-
mock_getoutput.side_effect = ['/dev/block/boot_a', '/dev/block/boot_b', 'a']
38-
with patch('builtins.input', return_value='a'):
39-
with patch('subprocess.check_call', MagicMock()) as mock_check_call:
40-
with patch('os.path.basename', MagicMock(return_value='test')):
41-
with patch('subprocess.getoutput', MagicMock(return_value='a')):
42-
from scripts.boot_image_extractor import extract_boot_image_dual_slot
43-
extract_boot_image_dual_slot('boot_a_path', 'boot_b_path')
44-
mock_check_call.assert_called_with(['dd', 'if=boot_a_path', 'of=./boota.img'])
45-
35+
@patch('builtins.input', return_value='a')
36+
def test_extract_boot_image_for_ab_device(self, mock_input, mock_getoutput, mock_print):
37+
mock_getoutput.side_effect = ['_a', 'a']
38+
with patch('subprocess.check_call', MagicMock()) as mock_check_call:
39+
extract_boot_image_for_ab_device('/dev/block/boot_a', '/dev/block/boot_b')
40+
mock_check_call.assert_called_with(['dd', 'if=/dev/block/boot_a', 'of=./boot_a.img'])
41+
42+
@patch('os.geteuid', MagicMock(return_value=0))
43+
@patch('subprocess.getoutput', return_value='')
44+
@patch('scripts.boot_image_extractor.print_banner', MagicMock())
45+
@patch('scripts.boot_image_extractor.exit_with_error')
46+
def test_main_no_boot_partition_found(self, mock_exit_with_error, mock_getoutput):
47+
main()
48+
mock_exit_with_error.assert_called_with("No boot partition found", "Unable to locate block device files.")
49+
50+
@patch('os.geteuid', MagicMock(return_value=0))
51+
@patch('subprocess.getoutput')
52+
@patch('builtins.print')
53+
def test_main_legacy_partition_style(self, mock_print, mock_getoutput):
54+
mock_getoutput.side_effect = ['/dev/block/boot', '', '']
55+
with patch('subprocess.check_call', MagicMock()) as mock_check_call:
56+
main()
57+
mock_print.assert_any_call("\n- Legacy(non-A/B) partition style detected!.")
58+
mock_check_call.assert_called_with(['dd', 'if=/dev/block/boot', 'of=./boot.img'])
59+
60+
@patch('os.geteuid', MagicMock(return_value=0))
61+
@patch('subprocess.getoutput')
62+
@patch('builtins.print')
63+
@patch('builtins.input', return_value='a')
64+
def test_main_ab_partition_style(self, mock_input, mock_print, mock_getoutput):
65+
# Set the side_effect to our infinite_side_effect function
66+
mock_getoutput.side_effect = self.infinite_side_effect('_a', '/dev/block/boot_a', '/dev/block/boot_b')
67+
with patch('subprocess.check_call', MagicMock()) as mock_check_call:
68+
main()
69+
mock_print.assert_any_call("\n- A/B partition style detected!.")
70+
mock_check_call.assert_called_with(['dd', 'if=/dev/block/boot_a', 'of=./boot_a.img'])
4671

4772
if __name__ == '__main__':
4873
unittest.main()
74+
75+

0 commit comments

Comments
 (0)