Skip to content
Draft
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
22 changes: 16 additions & 6 deletions googleplay.proto
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ message AndroidAppPatchData {
}
message AppFileMetadata {
optional int32 fileType = 1;
optional int32 versionCode = 2;
optional int64 versionCode = 2;
optional int64 size = 3;
optional string downloadUrl = 4;
optional int64 sizeGzipped = 6;
Expand Down Expand Up @@ -464,6 +464,11 @@ message Feature {
optional string label = 1;
optional string value = 3;
}
message DeviceFeature {
optional string name = 1;
optional int32 value = 2;
}

message DeviceConfigurationProto {
optional int32 touchScreen = 1;
optional int32 keyboard = 2;
Expand All @@ -482,6 +487,11 @@ message DeviceConfigurationProto {
repeated string glExtension = 15;
optional int32 deviceClass = 16;
optional int32 maxApkDownloadSizeMb = 17;
optional int32 smallestScreenWidthDP = 18;
optional int32 lowRamDevice = 19;
optional int64 totalMemoryBytes = 20;
optional int32 maxNumOfCPUCores = 21;
repeated DeviceFeature deviceFeature = 26;
}
message Document {
optional Docid docid = 1;
Expand Down Expand Up @@ -558,7 +568,7 @@ message AlbumDetails {
message AppDetails {
optional string developerName = 1;
optional int32 majorVersionNumber = 2;
optional int32 versionCode = 3;
optional int64 versionCode = 3;
optional string versionString = 4;
optional string title = 5;
repeated string appCategory = 7;
Expand Down Expand Up @@ -658,7 +668,7 @@ message Dependency {
}
message LibraryDependency {
optional string packageName = 1;
optional int32 versionCode = 2;
optional int64 versionCode = 2;
}
message TestingProgramInfo {
optional bool subscribed = 2;
Expand Down Expand Up @@ -695,7 +705,7 @@ message DocumentDetails {
}
message FileMetadata {
optional int32 fileType = 1;
optional int32 versionCode = 2;
optional int64 versionCode = 2;
optional int64 size = 3;
optional string splitId = 4;
optional int64 compressedSize = 5;
Expand Down Expand Up @@ -987,7 +997,7 @@ message LibraryUpdate {
}

message AndroidAppNotificationData {
optional int32 versionCode = 1;
optional int64 versionCode = 1;
optional string assetId = 2;
}
message InAppNotificationData {
Expand Down Expand Up @@ -1401,7 +1411,7 @@ message KeyToPackageNameMapping {
}
message PackageInfo {
optional string pkgName = 1;
optional int32 versionCode = 2;
optional int64 versionCode = 2;
}
message PayloadLevelAppStat {
optional int32 packageKey = 1;
Expand Down
29 changes: 26 additions & 3 deletions gpapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
import configparser


DFE_TARGETS = "CAEScFfqlIEG6gUYogFWrAISK1WDAg+hAZoCDgIU1gYEOIACFkLMAeQBnASLATlASUuyAyqCAjY5igOMBQzfA/IClwFbApUC4ANbtgKVAS7OAX8YswHFBhgDwAOPAmGEBt4OfKkB5weSB5AFASkiN68akgMaxAMSAQEBA9kBO7UBFE1KVwIDBGs3go6BBgEBAgMECQgJAQIEAQMEAQMBBQEBBAUEFQYCBgUEAwMBDwIBAgOrARwBEwMEAg0mrwESfTEcAQEKG4EBMxghChMBDwYGASI3hAEODEwXCVh/EREZA4sBYwEdFAgIIwkQcGQRDzQ2fTC2AjfVAQIBAYoBGRg2FhYFBwEqNzACJShzFFblAo0CFxpFNBzaAd0DHjIRI4sBJZcBPdwBCQGhAUd2A7kBLBVPngEECHl0UEUMtQETigHMAgUFCc0BBUUlTywdHDgBiAJ+vgKhAU0uAcYCAWQ/5ALUAw1UwQHUBpIBCdQDhgL4AY4CBQICjARbGFBGWzA1CAEMOQH+BRAOCAZywAIDyQZ2MgM3BxsoAgUEBwcHFia3AgcGTBwHBYwBAlcBggFxSGgIrAEEBw4QEqUCASsWadsHCgUCBQMD7QICA3tXCUw7ugJZAwGyAUwpIwM5AwkDBQMJA5sBCw8BNxBVVBwVKhebARkBAwsQEAgEAhESAgQJEBCZATMdzgEBBwG8AQQYKSMUkAEDAwY/CTs4/wEaAUt1AwEDAQUBAgIEAwYEDx1dB2wGeBFgTQ"
# DFE_TARGETS from rs-google-play (EFF's Rust library) - this is critical for getting full app details
DFE_TARGETS = "CAESN/qigQYC2AMBFfUbyA7SM5Ij/CvfBoIDgxHqGP8R3xzIBvoQtBKFDZ4HAY4FrwSVMasHBO0O2Q8akgYRAQECAQO7AQEpKZ0CnwECAwRrAQYBr9PPAoK7sQMBAQMCBAkIDAgBAwEDBAICBAUZEgMEBAMLAQEBBQEBAcYBARYED+cBfS8CHQEKkAEMMxcBIQoUDwYHIjd3DQ4MFk0JWGYZEREYAQOLAYEBFDMIEYMBAgICAgICOxkCD18LGQKEAcgDBIQBAgGLARkYCy8oBTJlBCUocxQn0QUBDkkGxgNZQq0BZSbeAmIDgAEBOgGtAaMCDAOQAZ4BBIEBKUtQUYYBQscDDxPSARA1oAEHAWmnAsMB2wFyywGLAxol+wImlwOOA80CtwN26A0WjwJVbQEJPAH+BRDeAfkHK/ABASEBCSAaHQemAzkaRiu2Ad8BdXeiAwEBGBUBBN4LEIABK4gB2AFLfwECAdoENq0CkQGMBsIBiQEtiwGgA1zyAUQ4uwS8AwhsvgPyAcEDF27vApsBHaICGhl3GSKxAR8MC6cBAgItmQYG9QIeywLvAeYBDArLAh8HASI4ELICDVmVBgsY/gHWARtcAsMBpALiAdsBA7QBpAJmIArpByn0AyAKBwHTARIHAX8D+AMBcRIBBbEDmwUBMacCHAciNp0BAQF0OgQLJDuSAh54kwFSP0eeAQQ4M5EBQgMEmwFXywFo0gFyWwMcapQBBugBPUW2AVgBKmy3AR6PAbMBGQxrUJECvQR+8gFoWDsYgQNwRSczBRXQAgtRswEW0ALMAREYAUEBIG6yATYCRE8OxgER8gMBvQEDRkwLc8MBTwHZAUOnAXiiBakDIbYBNNcCIUmuArIBSakBrgFHKs0EgwV/G3AD0wE6LgECtQJ4xQFwFbUCjQPkBS6vAQqEAUZF3QIM9wEhCoYCQhXsBCyZArQDugIziALWAdIBlQHwBdUErQE6qQaSA4EEIvYBHir9AQVLmgMCApsCKAwHuwgrENsBAjNYswEVmgIt7QJnN4wDEnta+wGfAcUBxgEtEFXQAQWdAUAeBcwBAQM7rAEJATJ0LENrdh73A6UBhAE+qwEeASxLZUMhDREuH0CGARbd7K0GlQo"
# DFE Phenotype - device fingerprint for Play Store client (from rs-google-play)
DFE_PHENOTYPE = "H4sIAAAAAAAAAB3OO3KjMAAA0KRNuWXukBkBQkAJ2MhgAZb5u2GCwQZbCH_EJ77QHmgvtDtbv-Z9_H63zXXU0NVPB1odlyGy7751Q3CitlPDvFd8lxhz3tpNmz7P92CFw73zdHU2Ie0Ad2kmR8lxhiErTFLt3RPGfJQHSDy7Clw10bg8kqf2owLokN4SecJTLoSwBnzQSd652_MOf2d1vKBNVedzg4ciPoLz2mQ8efGAgYeLou-l-PXn_7Sna1MfhHuySxt-4esulEDp8Sbq54CPPKjpANW-lkU2IZ0F92LBI-ukCKSptqeq1eXU96LD9nZfhKHdtjSWwJqUm_2r6pMHOxk01saVanmNopjX3YxQafC4iC6T55aRbC8nTI98AF_kItIQAJb5EQxnKTO7TZDWnr01HVPxelb9A2OWX6poidMWl16K54kcu_jhXw-JSBQkVcD_fPsLSZu6joIBAAA"
GOOGLE_PUBKEY = "AAAAgMom/1a/v0lblO2Ubrt60J2gcuXSljGFQXgcyZWveWLEwo6prwgi3iJIZdodyhKZQrNWp5nKJ3srRXcUW+F1BD3baEVGcmEgqaLZUNBjm057pKRI16kB0YppeGx5qIQ5QjKzsR8ETQbKLNWgRY0QRNVz34kMJR3P/LgHax/6rmf5AAAAAwEAAQ=="
ACCOUNT = "HOSTED_OR_GOOGLE"
GOOGLE_ACCEPTED_CIPHERS = "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE+AESGCM:ECDHE+CHACHA20:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-CCM8:ECDHE-ECDSA-AES256-CCM:ECDHE-ECDSA-AES128-CCM8:ECDHE-ECDSA-AES128-CCM:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:DHE+CHACHA20:ECDH+AESGCM:DH+AESGCM:ECDH+AES:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5:!DSS"
Expand Down Expand Up @@ -77,12 +80,16 @@ def setTimezone(self, timezone):
def getBaseHeaders(self):
return {"Accept-Language": self.locale.replace('_', '-'),
"X-DFE-Encoded-Targets": DFE_TARGETS,
"X-DFE-Phenotype": DFE_PHENOTYPE,
"User-Agent": self.getUserAgent(),
"X-DFE-Client-Id": "am-android-google",
"X-DFE-MCCMNC": self.device.get('celloperator'),
"X-DFE-Network-Type": "4",
"X-DFE-Content-Filters": "",
"X-DFE-Request-Params": "timeoutMs=4000"}
"X-DFE-Request-Params": "timeoutMs=4000",
"X-Limit-Ad-Tracking-Enabled": "false",
"X-Ad-Id": "",
"X-DFE-UserLanguages": self.locale}

def getDeviceUploadHeaders(self):
headers = self.getBaseHeaders()
Expand Down Expand Up @@ -176,6 +183,14 @@ def getDeviceConfig(self):
deviceConfig.screenWidth = int(self.device['screen.width'])
deviceConfig.screenHeight = int(self.device['screen.height'])
deviceConfig.glEsVersion = int(self.device['gl.version'])

# Additional fields from rs-google-play (critical for full API access)
deviceConfig.smallestScreenWidthDP = int(self.device.get('smallestscreenwidthdp', '320'))
deviceConfig.lowRamDevice = int(self.device.get('lowramdevice', '0'))
deviceConfig.totalMemoryBytes = int(self.device.get('totalmemory', '8354971648'))
deviceConfig.maxNumOfCPUCores = int(self.device.get('maxnumofcpucores', '8'))
deviceConfig.maxApkDownloadSizeMb = int(self.device.get('maxapkdownloadsizemb', '2048'))

for x in platforms:
deviceConfig.nativePlatform.append(x)
for x in libList:
Expand All @@ -186,6 +201,14 @@ def getDeviceConfig(self):
deviceConfig.systemSupportedLocale.append(x)
for x in glList:
deviceConfig.glExtension.append(x)

# Add deviceFeature array (critical - Rust sets this, Python was missing it)
for x in featureList:
deviceFeature = googleplay_pb2.DeviceFeature()
deviceFeature.name = x
deviceFeature.value = 0
deviceConfig.deviceFeature.append(deviceFeature)

return deviceConfig

def getAndroidBuild(self):
Expand All @@ -202,7 +225,7 @@ def getAndroidBuild(self):
androidBuild.buildProduct = self.device['build.product']
androidBuild.client = self.device['client']
androidBuild.otaInstalled = False
androidBuild.timestamp = int(time()/1000)
# Note: Rust does NOT set timestamp - leaving it unset like Rust does
androidBuild.googleServices = int(self.device['gsf.version'])
return androidBuild

Expand Down
111 changes: 109 additions & 2 deletions gpapi/googleplay.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,11 @@ def getHeaders(self, upload_fields=False):
if self.gsfId is not None:
headers["X-DFE-Device-Id"] = "{0:x}".format(self.gsfId)
if self.authSubToken is not None:
headers["Authorization"] = "GoogleLogin auth=%s" % self.authSubToken
# Use Bearer format for OAuth2 tokens (ya29.*), GoogleLogin for legacy tokens
if self.authSubToken.startswith('ya29.'):
headers["Authorization"] = "Bearer %s" % self.authSubToken
else:
headers["Authorization"] = "GoogleLogin auth=%s" % self.authSubToken
if self.device_config_token is not None:
headers["X-DFE-Device-Config-Token"] = self.device_config_token
if self.deviceCheckinConsistencyToken is not None:
Expand Down Expand Up @@ -212,6 +216,7 @@ def uploadDeviceConfig(self):
upload = googleplay_pb2.UploadDeviceConfigRequest()
upload.deviceConfiguration.CopyFrom(self.deviceBuilder.getDeviceConfig())
headers = self.getHeaders(upload_fields=True)
headers["Content-Type"] = CONTENT_TYPE_PROTO # Required for protobuf (matches Rust)
stringRequest = upload.SerializeToString()
response = self.session.post(UPLOAD_URL, data=stringRequest,
headers=headers,
Expand Down Expand Up @@ -290,6 +295,106 @@ def login(self, email=None, password=None, gsfId=None, authSubToken=None, return
else:
raise LoginError('Either (email,pass) or (gsfId, authSubToken) is needed')

def login_with_aas_token(self, email, aas_token):
"""Login using an AAS token (Android Account Service token).

This method follows the same authentication flow as rs-google-play:
1. Device checkin (device info only, no account association)
2. Upload device configuration
3. Get OAuth2 token using googleplay service
4. Get table of contents (dfeCookie)

Args:
email (str): Google account email
aas_token (str): AAS token (format: aas_et/...)

Returns:
tuple: (gsfId, authSubToken) for reuse
"""
# Step 1: Device checkin WITHOUT account association (like Rust)
self.gsfId = self._checkin_device_only()

# Step 2: Upload device configuration
self.uploadDeviceConfig()

# Step 3: Get auth token using oauth2 googleplay service (like Rust)
self._get_auth_token_oauth2(email, aas_token)

# Step 4: Get ToC to obtain dfeCookie
self.toc()

return self.gsfId, self.authSubToken

def _checkin_device_only(self):
"""Perform device checkin without account association."""
# Rust sends minimal headers for checkin - NOT the full X-DFE-* headers
headers = self.deviceBuilder.getAuthHeaders(self.gsfId)
headers["Content-Type"] = CONTENT_TYPE_PROTO
headers["Host"] = "android.clients.google.com"

request = self.deviceBuilder.getAndroidCheckinRequest()
stringRequest = request.SerializeToString()

res = self.session.post(CHECKIN_URL, data=stringRequest,
headers=headers, verify=self.ssl_verify,
proxies=self.proxies_config)
response = googleplay_pb2.AndroidCheckinResponse()
response.ParseFromString(res.content)

self.deviceCheckinConsistencyToken = response.deviceCheckinConsistencyToken
return response.androidId

def _get_auth_token_oauth2(self, email, aas_token):
"""Get auth token using oauth2 googleplay service (matches Rust flow).

Uses service 'oauth2:https://www.googleapis.com/auth/googleplay' instead
of 'androidmarket' which is what makes the Rust library work.
"""
# Use exact same params as Rust library
params = {
'Email': email,
'Token': aas_token,
'service': 'oauth2:https://www.googleapis.com/auth/googleplay',
'app': 'com.android.vending',
'callerPkg': 'com.google.android.gms', # Rust uses DEFAULT_ANDROID_VENDING
'callerSig': '38918a453d07199354f8b19af05ec6562ced5788',
'client_sig': '38918a453d07199354f8b19af05ec6562ced5788',
'device_country': 'us', # Rust uses DEFAULT_COUNTRY_CODE.to_ascii_lowercase()
'lang': 'en', # Rust uses DEFAULT_LANGUAGE.to_ascii_lowercase()
'sdk_version': self.deviceBuilder.device.get('build.version.sdk_int', '28'),
'google_play_services_version': self.deviceBuilder.device.get('gsf.version', '19629032'),
'oauth2_foreground': '1',
'token_request_options': 'CAA4AVAB',
'check_email': '1',
'system_partition': '1',
}

if self.gsfId is not None:
params['androidId'] = "{0:x}".format(self.gsfId)

headers = self.deviceBuilder.getAuthHeaders(self.gsfId)
headers['app'] = 'com.android.vending'

response = self.session.post(AUTH_URL,
data=params,
headers=headers,
verify=self.ssl_verify,
proxies=self.proxies_config)

data = response.text.split()
result = {}
for d in data:
if "=" not in d:
continue
k, v = d.split("=", 1)
result[k.strip().lower()] = v.strip()

if "auth" in result:
self.setAuthSubToken(result["auth"])
else:
error = result.get("error", "Unknown error")
raise LoginError(f"Failed to get auth token (oauth2): {error}\nFull response: {response.text[:500]}")

def getAuthSubToken(self, email, passwd):
requestParams = self.deviceBuilder.getLoginParams(email, passwd)
requestParams['service'] = 'androidmarket'
Expand Down Expand Up @@ -352,9 +457,10 @@ def executeRequestApi2(self, path, post_data=None, content_type=CONTENT_TYPE_URL
if self.authSubToken is None:
raise LoginError("You need to login before executing any request")
headers = self.getHeaders()
headers["Content-Type"] = content_type

if post_data is not None:
# Only set Content-Type for POST requests (matches Rust behavior)
headers["Content-Type"] = content_type
response = self.session.post(path,
data=str(post_data),
headers=headers,
Expand All @@ -363,6 +469,7 @@ def executeRequestApi2(self, path, post_data=None, content_type=CONTENT_TYPE_URL
timeout=60,
proxies=self.proxies_config)
else:
# GET requests don't need Content-Type (matches Rust behavior)
response = self.session.get(path,
headers=headers,
params=params,
Expand Down