Skip to content
This repository has been archived by the owner on Jul 27, 2024. It is now read-only.

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
ClaudiuGeorgiu committed Jan 10, 2018
2 parents b0cea41 + aefe01c commit d498f7a
Show file tree
Hide file tree
Showing 8 changed files with 123 additions and 54 deletions.
2 changes: 1 addition & 1 deletion .appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ environment:
global:
VENV_TEST_DIR: "venv"
CREDENTIALS:
secure: "6iORx0jZ39s+dqJlujrjlh2i/FssQELbhVdE7KRqXHb2yNro4lIhIfxliP317vdHAXGlKSudy/cA9lHVTFkeZKg8NcgRQQK33ZXx03MFrHWruszwzOyOs7De8QWrtb2XQmfvQF+Lh+e6dl8gpxR9IPj+n0keRhUWBghCiMf0gUjuG5lnRb/k1tausTxB2PjJaxm+z+IZ9IDhbTnW/p38hFqLfkXkYdagKYz23MYBtZcUmwj4uuERqrsNeNc7guwqP2kR9ne0y23IhgxYQBnYZA=="
secure: "6iORx0jZ39s+dqJlujrjlh2i/FssQELbhVdE7KRqXHb2yNro4lIhIfxliP317vdHAXGlKSudy/cA9lHVTFkeZKg8NcgRQQK33ZXx03MFrHWruszwzOyOs7De8QWrtb2XnlB+IB2LJe5mFRsKGt9EHfLTmSXdrhcrklfQekOsuKCXOEz2PcqSlFp4Mm7xeDyvmeBLqB5CM8Uhx28djf7seuvNkFHroLbInVeARzuy7EUhPgPDomcgmYTj8G2bLyXE4nuws1xNmkCG2R8yOaQzQw=="

install:
- "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
Expand Down
3 changes: 2 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ matrix:

env:
global:
- secure: "Uel6UUko0ESvgwnMW0+u6U95StIXMPoy/rBqlwkhYBESzyWogE0AmpzVC8+lqe6oZhYFp1lZfMcKGpixV/gw1IIOmEteiAp8TA8HUkDt1GgacYo4nZkiL0L7bS83XLBullhHAElhIhoHrCK082Fuax2F5lB54svadzvmExe7W62GXohZHrwzMAIMD6OFHv4z9ZbNRrmAFDh8z2TLwrDTE9/ETB1ns43YA0Ll46F+miLEvO+qndVBLidBTKIuudN8B8UmW+c8vGhvOXIuMjAojcik5Z052peZzRtd9bRxRc+IqElnHbHlE783ZgRGBIznYw/xSUNmt6FOjGHZjD6D0cYouj6URnprERe3xeKc8okScTdPEyHxg8c2qIsr8y5dzqXPZ72BG1k1b1iZ9/7aVND4qZEPEnUoCNa90aDvZgqobf18JqaqceR2WUHnnOrqLWzMRT9LeFu/YGru85AikNVeElo/dyww3NTZS9nQPbksPyhipQlz2XaXip5yyudjG5RAmo/PwljAqO9JrFuATksCp52mjoE5yxyAJ5U3WHsz3v+jQQI2mxnwwx7ImkAMc1sKpK+rj5NTliycGsx9daL8ZsbpiDYyh9kIEPE8LgZXE1vsv9vvxDJxfOzHhpxmWtD1qKkHHKl540MgB9buNfCjd41mmp8bLglHRS4c5AE="
# (printf CREDENTIALS=; json_minify private_credentials.json | base64 -w0) | travis encrypt
- secure: "Ry4Vi2T+IeqMTEcRY2Y5c567Gszvj/W9Yd6wqr5XUag7bv7lAwxb9eVKOE2msGHY5Blu5hmLlI1CIJIbSGaIHocls2zLXx8fDXruFetnimvnfxvvSMXk0/y6Byf0SJeo+WzurVUMI1rLBbcwz3xVaasIe11OKpbjYHs+7dgjm76W8fiE4T39gC0MqcwN8J5NK8ddDtmw5JmaRABV+/WYnJ670v/C2Rt5Q0XAS3qZtHoyE9hHJWXw2d1afIr5fxZ3kBgN5LGp6b5mjxi6n4kegkoWVmOm/MZdGg0WAdo5ygYJhNMsbV66gWHxfCKX3uHBhl/fyV8eDe7Syb0py0d4PnFPa9M9R6p17gYB6SPoJTTlx+dYimzGB9CoRxplftrcV0V3A36qYqPoyrL/c+FoxJrDLpmX2JkNL1xG3DMBKDmpSrGmpha8YKewv+XuPPCrOCh47+n9sM3kQJVjhDICywo/ia3X7dk3aCdi4aCgnTAVwqSQY0V/C9jo3mGhgAfvj6MH4+WvYsatX5u4C7SoKeMmuORhK+rkeEqYY/VQn65vblG0wyPbOTGl0H93YkH8F1S6Qy598PREIqG0lcq6H/67oQMDVwE8DgZ3/cFdZHafa91PERDkw9h0LLPYyAhKcwLIdBuJbH7oMRYc+BGpZIUDSyqFPTrBdXUJY393l3Y="

before_install:
- "if [[ \"$TRAVIS_OS_NAME\" == \"osx\" ]]; then brew update; fi"
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ COPY ./download.py /app/
COPY ./credentials.json /app/

WORKDIR /app

# Run with -u $(id -u):$(id -g) to avoid file permission issues
ENTRYPOINT ["python3", "/app/download.py"]
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ $ python3 download.py "com.application.example"

If the download is successful, the resulting `.apk` file will be saved in the `PlaystoreDownloader/Downloads` directory. You can change the name and the location of the downloaded `.apk` file by providing an additional `-o "path/to/downloaded.apk"` argument to [download.py](https://github.com/ClaudiuGeorgiu/PlaystoreDownloader/blob/master/download.py) (type `$ python3 download.py --help` for more information).

Docker is also supported:

```Shell
# Make sure to have valid credentials inside credentials.json file before building the image
$ docker build -t downloader .

# Download the selected application in the current directory
$ docker run --rm -u $(id -u):$(id -g) -v "$PWD":"/app/Downloads" downloader "com.application.example"
```


## Contributing
Expand Down
Binary file modified demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 15 additions & 1 deletion download.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ def get_cmd_args(args: list = None):
parser = argparse.ArgumentParser(description='Download an application (.apk) from the Google Play Store.')
parser.add_argument('package', type=str, help='The package name of the application to be downloaded, '
'e.g. "com.spotify.music" or "com.whatsapp"')
parser.add_argument('-b', '--blobs', action='store_true',
help='Download the additional .obb files along with the application (if any)')
parser.add_argument('-c', '--credentials', type=str, metavar='CREDENTIALS', default=credentials_default_location,
help='The path to the JSON configuration file containing the store credentials. By '
'default the "credentials.json" file will be used')
parser.add_argument('-o', '--out', type=str, metavar='FILE', default=downloaded_apk_default_location,
help='The path where to save the downloaded .apk file. By default the file will be saved '
'in a "Downloads/" directory created where this script is run')
parser.add_argument('-t', '--tag', type=str, metavar='TAG',
help='An optional tag prepended to the file name, e.g., "[TAG] filename.apk"')
return parser.parse_args(args)


Expand Down Expand Up @@ -69,7 +73,17 @@ def main():
if not os.path.exists(os.path.dirname(downloaded_apk_file_path)):
os.makedirs(os.path.dirname(downloaded_apk_file_path))

success = api.download(details['package_name'], downloaded_apk_file_path)
if args.tag and args.tag.strip(' \'"'):
# If provided, prepend the specified tag to the file name.
downloaded_apk_file_path = os.path.join(os.path.dirname(downloaded_apk_file_path),
'[{0}] {1}'.format(args.tag.strip(' \'"'),
os.path.basename(downloaded_apk_file_path)))

# The download of the additional .obb files is optional.
if args.blobs:
success = api.download(details['package_name'], downloaded_apk_file_path, download_obb=True)
else:
success = api.download(details['package_name'], downloaded_apk_file_path, download_obb=False)

if not success:
print('Error when downloading "{0}".'.format(details['package_name']))
Expand Down
125 changes: 76 additions & 49 deletions playstore/playstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import requests
from google.protobuf import json_format
from requests.exceptions import ChunkedEncodingError
from tqdm import tqdm

from . import playstore_proto_pb2 as playstore_protobuf
Expand Down Expand Up @@ -165,6 +166,29 @@ def _execute_request(self, path: str, data: str = None) -> object:

return message

@staticmethod
def _check_entire_file_downloaded(expected_size: int, downloaded_file_path: str) -> bool:
"""
Check if a file was entirely downloaded by comparing the actual size of the file
with the expected size of the file.
:param expected_size: Size (in bytes) of the file to download.
:param downloaded_file_path: The complete path where the file has been downloaded.
:return: True if the entire file was written to disk, False otherwise.
"""

if expected_size != os.path.getsize(downloaded_file_path):
logging.error('Download of "{0}" not completed, please retry. The file "{0}" is corrupted '
'and will be removed.'.format(downloaded_file_path))
try:
os.remove(downloaded_file_path)
except OSError:
logging.warning('The file "{0}" is corrupted and should be removed manually.'
.format(downloaded_file_path))

return False
else:
return True

############################
# Playstore Public Methods #
############################
Expand Down Expand Up @@ -290,7 +314,7 @@ def search(self, query: str, num_of_results: int = None) -> object:
def app_details(self, package_name: str) -> object:
"""
Get the details for a certain app (identified by the package name) in the Google Play Store.
:param package_name: The package name of the app (e.g. "com.example.myapp").
:param package_name: The package name of the app (e.g., "com.example.myapp").
:return: A protobuf object containing the details of the app. The result
will be None if there was something wrong with the query.
"""
Expand All @@ -314,11 +338,13 @@ def app_details(self, package_name: str) -> object:

return details

def download(self, package_name: str, file_name: str = None) -> bool:
def download(self, package_name: str, file_name: str = None, download_obb: bool = False) -> bool:
"""
Download a certain app (identified by the package name) from the Google Play Store.
:param package_name: The package name of the app (e.g. "com.example.myapp").
:param package_name: The package name of the app (e.g., "com.example.myapp").
:param file_name: The location where to save the downloaded app (by default "package_name.apk").
:param download_obb: Flag indicating whether to also download the additional .obb files for
an application (if any).
:return: True if the file was downloaded correctly, False otherwise.
"""

Expand Down Expand Up @@ -357,7 +383,7 @@ def download(self, package_name: str, file_name: str = None) -> bool:
temp_url = response.payload.buyResponse.purchaseStatusResponse.appDeliveryData.downloadUrl

# Additional files (.obb) to be downloaded with the apk.
additional_files = [additional_file.downloadUrl for additional_file in
additional_files = [additional_file for additional_file in
response.payload.buyResponse.purchaseStatusResponse.appDeliveryData.additionalFile]

try:
Expand All @@ -382,56 +408,57 @@ def download(self, package_name: str, file_name: str = None) -> bool:
apk_size = int(response.headers['content-length'])

# Download the apk file and save it, showing a progress bar.
with open(file_name, 'wb') as f:
for chunk in tqdm(response.iter_content(chunk_size=chunk_size), total=(apk_size // chunk_size),
dynamic_ncols=True, unit=' KB', desc=('Downloading {0}'.format(package_name)),
bar_format='{l_bar}{bar}|[{elapsed}<{remaining}, {rate_fmt}]'):
if chunk:
f.write(chunk)
f.flush()

# Check if the entire file was downloaded correctly.
if apk_size != os.path.getsize(file_name):
logging.error('Download not completed for "{0}". The file "{1}" is corrupted '
'and will be removed.'.format(package_name, file_name))
try:
os.remove(file_name)
except OSError:
logging.warning('The file "{0}" is corrupted and should be removed manually.'.format(file_name))

return False

# Save the additional files for the apk.
for index, file_url in enumerate(additional_files):

# Execute another query to get the actual file.
response = requests.get(file_url, headers=headers, cookies=cookies, verify=True, stream=True)

chunk_size = 1024
file_size = int(response.headers['content-length'])

additional_file_name = '{0}-additional-file-{1}.obb'.format(file_name, index + 1)

# Download the apk file and save it, showing a progress bar.
with open(additional_file_name, 'wb') as f:
for chunk in tqdm(response.iter_content(chunk_size=chunk_size), total=(file_size // chunk_size),
dynamic_ncols=True, unit=' KB',
desc=('Downloading additional file {0}'.format(index + 1)),
try:
with open(file_name, 'wb') as f:
for chunk in tqdm(response.iter_content(chunk_size=chunk_size), total=(apk_size // chunk_size),
dynamic_ncols=True, unit=' KB', desc=('Downloading {0}'.format(package_name)),
bar_format='{l_bar}{bar}|[{elapsed}<{remaining}, {rate_fmt}]'):
if chunk:
f.write(chunk)
f.flush()
except ChunkedEncodingError:
# There was an error during the download so not all the file was written to disk, hence there will
# be a mismatch between the expected size and the actual size of the downloaded file, but the next
# code block will handle that.
pass

# Check if the entire apk was downloaded correctly, otherwise return immediately.
if not self._check_entire_file_downloaded(apk_size, file_name):
return False

# Check if the entire additional file was downloaded correctly.
if file_size != os.path.getsize(additional_file_name):
logging.error('Download not completed for additional file {0} of "{1}". The file "{2}" is corrupted '
'and will be removed.'.format(index + 1, package_name, additional_file_name))
try:
os.remove(additional_file_name)
except OSError:
logging.warning('The file "{0}" is corrupted and should be removed manually.'
.format(additional_file_name))
if download_obb:
# Save the additional files for the apk.
for obb in additional_files:

return False
# Execute another query to get the actual file.
response = requests.get(obb.downloadUrl, headers=headers, cookies=cookies, verify=True, stream=True)

chunk_size = 1024
file_size = int(response.headers['content-length'])

obb_file_name = os.path.join(os.path.dirname(file_name),
'{0}.{1}.{2}.obb'.format('main' if obb.fileType == 0 else 'patch',
obb.versionCode, package_name))

# Download the additional file and save it, showing a progress bar.
try:
with open(obb_file_name, 'wb') as f:
for chunk in tqdm(response.iter_content(chunk_size=chunk_size), total=(file_size // chunk_size),
dynamic_ncols=True, unit=' KB',
desc=('Downloading additional file of {0}'.format(package_name)),
bar_format='{l_bar}{bar}|[{elapsed}<{remaining}, {rate_fmt}]'):
if chunk:
f.write(chunk)
f.flush()
except ChunkedEncodingError:
# There was an error during the download so not all the file was written to disk, hence there will
# be a mismatch between the expected size and the actual size of the downloaded file, but the next
# code block will handle that.
pass

# Check if the entire additional file was downloaded correctly, otherwise return immediately.
if not self._check_entire_file_downloaded(file_size, obb_file_name):
return False

# The apk and the additional files (if any) were downloaded correctly.
return True
20 changes: 18 additions & 2 deletions test/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ def test_valid_download_specific_location(self, download_folder_path, valid_cred

assert os.path.isfile(downloaded_apk_path) is True

def test_valid_download_specific_location_with_tag(self, download_folder_path, valid_credentials_path, monkeypatch):

downloaded_apk_path = '{0}.apk'.format(os.path.join(download_folder_path, VALID_PACKAGE_NAME))
downloaded_apk_path_with_tag = '{0}.apk'.format(os.path.join(download_folder_path,
'[TEST] {0}'.format(VALID_PACKAGE_NAME)))

# Mock the command line parser.
arguments = download.get_cmd_args(
'"{0}" -c "{1}" -o "{2}" -t "TEST"'.format(VALID_PACKAGE_NAME, valid_credentials_path,
downloaded_apk_path).split())
monkeypatch.setattr(download, 'get_cmd_args', lambda: arguments)

download.main()

assert os.path.isfile(downloaded_apk_path_with_tag) is True

def test_valid_download_default_location(self, valid_credentials_path, monkeypatch):
# Mock the command line parser.
arguments = download.get_cmd_args('"{0}" -c "{1}"'.format(VALID_PACKAGE_NAME, valid_credentials_path).split())
Expand All @@ -40,7 +56,7 @@ def test_valid_download_default_location(self, valid_credentials_path, monkeypat

def test_valid_download_additional_files(self, valid_credentials_path, monkeypatch):
# Mock the command line parser.
arguments = download.get_cmd_args('"{0}" -c "{1}"'.format(APK_WITH_OBB, valid_credentials_path).split())
arguments = download.get_cmd_args('"{0}" -b -c "{1}"'.format(APK_WITH_OBB, valid_credentials_path).split())
monkeypatch.setattr(download, 'get_cmd_args', lambda: arguments)

# If this runs without errors, the apk and the additional files will be saved in the Downloads folder
Expand Down Expand Up @@ -68,7 +84,7 @@ def test_download_error(self, download_folder_path, valid_credentials_path, monk
monkeypatch.setattr(download, 'get_cmd_args', lambda: arguments)

# Mock the Playstore.
monkeypatch.setattr(Playstore, 'download', lambda self, package, path: False)
monkeypatch.setattr(Playstore, 'download', lambda self, package, path, download_obb: False)

with pytest.raises(SystemExit) as err:
download.main()
Expand Down

0 comments on commit d498f7a

Please sign in to comment.