Passed
Push — master ( 7c53b4...bb114b )
by Alexey
07:39 queued 10s
created

GPlayApps   F

Complexity

Total Complexity 112

Size/Duplication

Total Lines 1362
Duplicated Lines 0 %

Test Coverage

Coverage 88.63%

Importance

Changes 2
Bugs 1 Features 0
Metric Value
wmc 112
eloc 436
c 2
b 1
f 0
dl 0
loc 1362
ccs 382
cts 431
cp 0.8863
rs 2

37 Methods

Rating   Name   Duplication   Size   Complexity  
A getListApps() 0 3 1
A getTopApps() 0 3 1
A getNewApps() 0 3 1
A existsApp() 0 15 2
A createUrlListFromAppIds() 0 9 2
A getAppInfoForLocales() 0 10 2
A getSearchSuggestions() 0 31 3
A setCache() 0 6 1
A getDeveloperInfoForLocales() 0 41 5
A getDeveloperInfo() 0 33 3
A getAppsInfo() 0 18 3
A getReviewById() 0 24 2
A getCategoriesForLocales() 0 29 4
A setConcurrency() 0 5 1
A getDefaultLocale() 0 3 1
A setDefaultCountry() 0 7 2
A getPermissions() 0 19 2
A getCategories() 0 21 2
A getSimilarApps() 0 28 3
C fetchAppsFromClusterPages() 0 72 13
A getCategoriesForAvailableLocales() 0 3 1
A getHttpClient() 0 16 2
B fetchAppsFromClusterPage() 0 62 10
A setConnectTimeout() 0 5 1
A getDefaultCountry() 0 3 1
B saveGoogleImages() 0 87 9
A getAppInfo() 0 3 1
B getReviews() 0 36 8
A setTimeout() 0 5 1
A setCacheTtl() 0 6 1
A search() 0 23 2
A existsApps() 0 19 3
A setProxy() 0 5 1
B getAppInfoForAvailableLocales() 0 68 11
A getDeveloperApps() 0 42 4
A setDefaultLocale() 0 5 1
A __construct() 0 7 1

How to fix   Complexity   

Complex Class

Complex classes like GPlayApps often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use GPlayApps, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @author   Ne-Lexa
7
 * @license  MIT
8
 *
9
 * @see      https://github.com/Ne-Lexa/google-play-scraper
10
 */
11
12
namespace Nelexa\GPlay;
13
14
use GuzzleHttp\Promise\EachPromise;
15
use GuzzleHttp\Promise\FulfilledPromise;
16
use GuzzleHttp\RequestOptions;
17
use Nelexa\HttpClient\HttpClient;
18
use Nelexa\HttpClient\Options;
19
use Psr\Http\Message\ResponseInterface;
20
use Psr\Http\Message\StreamInterface;
21
use Psr\SimpleCache\CacheInterface;
22
23
use function GuzzleHttp\Psr7\build_query;
24
use function GuzzleHttp\Psr7\parse_query;
25
26
/**
27
 * Contains methods for extracting information about Android applications from the Google Play store.
28
 */
29
class GPlayApps
30
{
31
    /** @var string Default request locale. */
32
    public const DEFAULT_LOCALE = 'en_US';
33
34
    /** @var string Default request country. */
35
    public const DEFAULT_COUNTRY = 'us';
36
37
    /** @var string Google Play base url. */
38
    public const GOOGLE_PLAY_URL = 'https://play.google.com';
39
40
    /** @var string Google Play apps url. */
41
    public const GOOGLE_PLAY_APPS_URL = self::GOOGLE_PLAY_URL . '/store/apps';
42
43
    /** @var int Unlimit results. */
44
    public const UNLIMIT = -1;
45
46
    /** @internal */
47
    public const REQ_PARAM_LOCALE = 'hl';
48
49
    /** @internal */
50
    public const REQ_PARAM_COUNTRY = 'gl';
51
52
    /** @internal */
53
    public const REQ_PARAM_ID = 'id';
54
55
    /** @var int Limit of parallel HTTP requests */
56
    protected $concurrency = 4;
57
58
    /** @var string Locale (language) for HTTP requests to Google Play */
59
    protected $defaultLocale;
60
61
    /** @var string Country for HTTP requests to Google Play */
62
    protected $defaultCountry;
63
64
    /**
65
     * Creates an object to retrieve data about Android applications from the Google Play store.
66
     *
67
     * @param string $locale  locale (language) for HTTP requests to Google Play
68
     *                        or {@see GPlayApps::DEFAULT_LOCALE}
69
     * @param string $country country for HTTP requests to Google Play
70
     *                        or {@see GPlayApps::DEFAULT_COUNTRY}
71
     *
72
     * @see GPlayApps::DEFAULT_LOCALE Default request locale.
73
     * @see GPlayApps::DEFAULT_COUNTRY Default request country.
74
     */
75 70
    public function __construct(
76
        string $locale = self::DEFAULT_LOCALE,
77
        string $country = self::DEFAULT_COUNTRY
78
    ) {
79
        $this
80 70
            ->setDefaultLocale($locale)
81 70
            ->setDefaultCountry($country)
82
        ;
83 70
    }
84
85
    /**
86
     * Sets caching for HTTP requests.
87
     *
88
     * @param cacheInterface|null    $cache    PSR-16 Simple Cache instance
89
     * @param \DateInterval|int|null $cacheTtl TTL cached data
90
     *
91
     * @return GPlayApps returns the current class instance to allow method chaining
92
     */
93 17
    public function setCache(?CacheInterface $cache, $cacheTtl = null): self
94
    {
95 17
        $this->getHttpClient()->setCache($cache);
96 17
        $this->setCacheTtl($cacheTtl);
97
98 17
        return $this;
99
    }
100
101
    /**
102
     * Sets cache ttl.
103
     *
104
     * @param \DateInterval|int|null $cacheTtl TTL cached data
105
     *
106
     * @return GPlayApps returns the current class instance to allow method chaining
107
     */
108 17
    public function setCacheTtl($cacheTtl): self
109
    {
110 17
        $cacheTtl = $cacheTtl ?? \DateInterval::createFromDateString('5 min');
111 17
        $this->getHttpClient()->setCacheTtl($cacheTtl);
112
113 17
        return $this;
114
    }
115
116
    /**
117
     * Returns an instance of HTTP client.
118
     *
119
     * @return HttpClient http client
120
     */
121 58
    protected function getHttpClient(): HttpClient
122
    {
123 58
        static $httpClient;
124
125 58
        if ($httpClient === null) {
126 1
            $httpClient = new HttpClient(
127
                [
128 1
                    Options::TIMEOUT => 10.0,
129 1
                    Options::HEADERS => [
130
                        'User-Agent' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:72.0) Gecko/20100101 Firefox/72.0',
131
                    ],
132
                ]
133
            );
134
        }
135
136 58
        return $httpClient;
137
    }
138
139
    /**
140
     * Sets the limit of concurrent HTTP requests.
141
     *
142
     * @param int $concurrency maximum number of concurrent HTTP requests
143
     *
144
     * @return GPlayApps returns the current class instance to allow method chaining
145
     */
146 4
    public function setConcurrency(int $concurrency): self
147
    {
148 4
        $this->concurrency = max(1, $concurrency);
149
150 4
        return $this;
151
    }
152
153
    /**
154
     * Sets proxy for outgoing HTTP requests.
155
     *
156
     * @param string|null $proxy Proxy url, ex. socks5://127.0.0.1:9050 or https://116.90.233.2:47348
157
     *
158
     * @return GPlayApps returns the current class instance to allow method chaining
159
     *
160
     * @see https://curl.haxx.se/libcurl/c/CURLOPT_PROXY.html Description of proxy URL formats in CURL.
161
     */
162
    public function setProxy(?string $proxy): self
163
    {
164
        $this->getHttpClient()->setProxy($proxy);
165
166
        return $this;
167
    }
168
169
    /**
170
     * Returns the full detail of an application.
171
     *
172
     * For information, you must specify the application ID (android package name).
173
     * The application ID can be viewed in the Google Play store:
174
     * `https://play.google.com/store/apps/details?id=XXXXXX` , where
175
     * XXXXXX is the application id.
176
     *
177
     * Or it can be found in the APK file.
178
     * ```shell
179
     * aapt dump badging file.apk | grep package | awk '{print $2}' | sed s/name=//g | sed s/\'//g
180
     * ```
181
     *
182
     * @param string|Model\AppId $appId google play app id (Android package name)
183
     *
184
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
185
     *
186
     * @return Model\AppInfo full detail of an application or exception
187
     *
188
     * @api
189
     */
190 8
    public function getAppInfo($appId): Model\AppInfo
191
    {
192 8
        return $this->getAppsInfo([$appId])[0];
193
    }
194
195
    /**
196
     * Returns the full detail of multiple applications.
197
     *
198
     * The keys of the returned array matches to the passed array.
199
     * HTTP requests are executed in parallel.
200
     *
201
     * @param string[]|Model\AppId[] $appIds array of application ids
202
     *
203
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
204
     *
205
     * @return Model\AppInfo[] an array of detailed information for each application
206
     *
207
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
208
     *
209
     * @api
210
     */
211 26
    public function getAppsInfo(array $appIds): array
212
    {
213 26
        if (empty($appIds)) {
214 1
            return [];
215
        }
216 25
        $urls = $this->createUrlListFromAppIds($appIds);
217
218
        try {
219 23
            return $this->getHttpClient()->requestAsyncPool(
220 23
                'GET',
221 23
                $urls,
222
                [
223 23
                    Options::HANDLER_RESPONSE => new Scraper\AppInfoScraper(),
224
                ],
225 23
                $this->concurrency
226
            );
227 2
        } catch (\Throwable $e) {
228 2
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
229
        }
230
    }
231
232
    /**
233
     * Returns an array of URLs for application ids.
234
     *
235
     * @param string[]|Model\AppId[] $appIds array of application ids
236
     *
237
     * @return string[] an array of URL
238
     */
239 26
    final protected function createUrlListFromAppIds(array $appIds): array
240
    {
241 26
        $urls = [];
242
243 26
        foreach ($appIds as $key => $appId) {
244 26
            $urls[$key] = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry)->getFullUrl();
245
        }
246
247 24
        return $urls;
248
    }
249
250
    /**
251
     * Returns the full details of an application in multiple languages.
252
     *
253
     * HTTP requests are executed in parallel.
254
     *
255
     * @param string|Model\AppId $appId   google Play app ID (Android package name)
256
     * @param string[]           $locales array of locales
257
     *
258
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
259
     *
260
     * @return Model\AppInfo[] An array of detailed information for each locale.
261
     *                         The array key is the locale.
262
     *
263
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
264
     *
265
     * @api
266
     */
267 15
    public function getAppInfoForLocales($appId, array $locales): array
268
    {
269 15
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
270 14
        $requests = [];
271
272 14
        foreach ($locales as $locale) {
273 14
            $requests[$locale] = new Model\AppId($appId->getId(), $locale, $appId->getCountry());
274
        }
275
276 14
        return $this->getAppsInfo($requests);
277
    }
278
279
    /**
280
     * Returns detailed application information for all available locales.
281
     *
282
     * Information is returned only for the description loaded by the developer.
283
     * All locales with automated translation from Google Translate will be ignored.
284
     * HTTP requests are executed in parallel.
285
     *
286
     * @param string|Model\AppId $appId application ID (Android package name) as
287
     *                                  a string or {@see Model\AppId} object
288
     *
289
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
290
     *
291
     * @return Model\AppInfo[] An array with detailed information about the application
292
     *                         on all available locales. The array key is the locale.
293
     *
294
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
295
     *
296
     * @api
297
     */
298 14
    public function getAppInfoForAvailableLocales($appId): array
299
    {
300 14
        $list = $this->getAppInfoForLocales($appId, Util\LocaleHelper::SUPPORTED_LOCALES);
301
302 13
        $preferredLocale = self::DEFAULT_LOCALE;
303
304 13
        foreach ($list as $app) {
305 13
            if ($app->isAutoTranslatedDescription()) {
306 13
                $preferredLocale = (string) $app->getTranslatedFromLocale();
307 13
                break;
308
            }
309
        }
310
311
        /**
312
         * @var Model\AppInfo[] $list
313
         */
314 13
        $list = array_filter(
315 13
            $list,
316
            static function (Model\AppInfo $app) {
317 13
                return !$app->isAutoTranslatedDescription();
318 13
            }
319
        );
320
321 13
        if (!isset($list[$preferredLocale])) {
322
            throw new \RuntimeException('No key ' . $preferredLocale);
323
        }
324 13
        $preferredApp = $list[$preferredLocale];
325 13
        $list = array_filter(
326 13
            $list,
327
            static function (Model\AppInfo $app, string $locale) use ($preferredApp, $list) {
328
                // deletes locales in which there is no translation added, but automatic translation by Google Translate is used.
329 13
                if ($preferredApp->getLocale() === $locale || !$preferredApp->equals($app)) {
330 13
                    if (($pos = strpos($locale, '_')) !== false) {
331 13
                        $rootLang = substr($locale, 0, $pos);
332 13
                        $rootLangLocale = Util\LocaleHelper::getNormalizeLocale($rootLang);
333
334
                        if (
335 13
                            $rootLangLocale !== $locale &&
336 13
                            isset($list[$rootLangLocale]) &&
337 13
                            $list[$rootLangLocale]->equals($app)
338
                        ) {
339
                            // delete duplicate data,
340
                            // for example, delete en_CA, en_IN, en_GB, en_ZA, if there is en_US and they are equals.
341 9
                            return false;
342
                        }
343
                    }
344
345 13
                    return true;
346
                }
347
348 8
                return false;
349 13
            },
350 13
            \ARRAY_FILTER_USE_BOTH
351
        );
352
353
        // sorting array keys; the first key is the preferred locale
354 13
        uksort(
355 13
            $list,
356
            static function (
357
                /** @noinspection PhpUnusedParameterInspection */
358
                string $a,
359
                string $b
360
            ) use ($preferredLocale) {
361 13
                return $b === $preferredLocale ? 1 : 0;
362 13
            }
363
        );
364
365 13
        return $list;
366
    }
367
368
    /**
369
     * Checks if the specified application exists in the Google Play store.
370
     *
371
     * @param string|Model\AppId $appId application ID (Android package name) as
372
     *                                  a string or {@see Model\AppId} object
373
     *
374
     * @return bool returns `true` if the application exists, or `false` if not
375
     *
376
     * @api
377
     */
378 5
    public function existsApp($appId): bool
379
    {
380 5
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
381
382
        try {
383 5
            return (bool) $this->getHttpClient()->request(
384 5
                'HEAD',
385 5
                $appId->getFullUrl(),
386
                [
387 5
                    RequestOptions::HTTP_ERRORS => false,
388 5
                    Options::HANDLER_RESPONSE => new Scraper\ExistsAppScraper(),
389
                ]
390
            );
391
        } catch (\Throwable $e) {
392
            return false;
393
        }
394
    }
395
396
    /**
397
     * Checks if the specified applications exist in the Google Play store.
398
     * HTTP requests are executed in parallel.
399
     *
400
     * @param string[]|Model\AppId[] $appIds Array of application identifiers.
401
     *                                       The keys of the returned array correspond to the transferred array.
402
     *
403
     * @throws Exception\GooglePlayException if an HTTP error other than 404 is received
404
     *
405
     * @return bool[] An array of information about the existence of each
406
     *                application in the store Google Play. The keys of the returned
407
     *                array matches to the passed array.
408
     *
409
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
410
     *
411
     * @api
412
     */
413 1
    public function existsApps(array $appIds): array
414
    {
415 1
        if (empty($appIds)) {
416
            return [];
417
        }
418 1
        $urls = $this->createUrlListFromAppIds($appIds);
419
420
        try {
421 1
            return $this->getHttpClient()->requestAsyncPool(
422 1
                'HEAD',
423 1
                $urls,
424
                [
425 1
                    RequestOptions::HTTP_ERRORS => false,
426 1
                    Options::HANDLER_RESPONSE => new Scraper\ExistsAppScraper(),
427
                ],
428 1
                $this->concurrency
429
            );
430
        } catch (\Throwable $e) {
431
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
432
        }
433
    }
434
435
    /**
436
     * Returns reviews of the Android app in the Google Play store.
437
     *
438
     * Getting a lot of reviews can take a lot of time.
439
     *
440
     * @param string|Model\AppId $appId application ID (Android package name) as
441
     *                                  a string or {@see Model\AppId} object
442
     * @param int                $limit Maximum number of reviews. To extract all
443
     *                                  reviews, use {@see GPlayApps::UNLIMIT}.
444
     * @param Enum\SortEnum|null $sort  Sort reviews of the application.
445
     *                                  If null, then sort by the newest reviews.
446
     *
447
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
448
     *
449
     * @return Model\Review[] app reviews
450
     *
451
     * @see Enum\SortEnum Contains all valid values for the "sort" parameter.
452
     * @see GPlayApps::UNLIMIT Limit for all available results.
453
     *
454
     * @api
455
     */
456 1
    public function getReviews($appId, int $limit = 100, ?Enum\SortEnum $sort = null): array
457
    {
458 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
459 1
        $sort = $sort ?? Enum\SortEnum::NEWEST();
460
461 1
        $allCount = 0;
462 1
        $token = null;
463 1
        $allReviews = [];
464
465 1
        $cacheTtl = $sort === Enum\SortEnum::NEWEST() ?
466 1
            \DateInterval::createFromDateString('1 min') :
467 1
            \DateInterval::createFromDateString('1 hour');
468
469
        try {
470
            do {
471 1
                $count = $limit === self::UNLIMIT ?
472
                    Scraper\PlayStoreUiRequest::LIMIT_REVIEW_ON_PAGE :
473 1
                    min(Scraper\PlayStoreUiRequest::LIMIT_REVIEW_ON_PAGE, max($limit - $allCount, 1));
474
475 1
                $request = Scraper\PlayStoreUiRequest::getReviewsRequest($appId, $count, $sort, $token);
476
477 1
                [$reviews, $token] = $this->getHttpClient()->send(
478 1
                    $request,
479
                    [
480 1
                        Options::CACHE_TTL => $cacheTtl,
481 1
                        Options::HANDLER_RESPONSE => new Scraper\ReviewsScraper($appId),
482
                    ]
483
                );
484 1
                $allCount += \count($reviews);
485 1
                $allReviews[] = $reviews;
486 1
            } while ($token !== null && ($limit === self::UNLIMIT || $allCount < $limit));
487
        } catch (\Throwable $e) {
488
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
489
        }
490
491 1
        return empty($allReviews) ? $allReviews : array_merge(...$allReviews);
492
    }
493
494
    /**
495
     * Returns review of the Android app in the Google Play store by review id.
496
     *
497
     * @param string|Model\AppId $appId    application ID (Android package name) as
498
     *                                     a string or {@see Model\AppId} object
499
     * @param string             $reviewId review id
500
     *
501
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
502
     *
503
     * @return Model\Review app review
504
     */
505 1
    public function getReviewById($appId, string $reviewId): Model\Review
506
    {
507 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
508
509
        try {
510
            /** @var Model\Review $review */
511 1
            $review = $this->getHttpClient()->request(
512 1
                'GET',
513 1
                self::GOOGLE_PLAY_APPS_URL . '/details',
514
                [
515 1
                    RequestOptions::QUERY => [
516 1
                        self::REQ_PARAM_ID => $appId->getId(),
517 1
                        self::REQ_PARAM_LOCALE => $appId->getLocale(),
518 1
                        self::REQ_PARAM_COUNTRY => $appId->getCountry(),
519 1
                        'reviewId' => $reviewId,
520
                    ],
521 1
                    Options::HANDLER_RESPONSE => new Scraper\AppSpecificReviewScraper($appId),
522
                ]
523
            );
524
        } catch (\Throwable $e) {
525
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
526
        }
527
528 1
        return $review;
529
    }
530
531
    /**
532
     * Returns a list of permissions for the application.
533
     *
534
     * @param string|Model\AppId $appId application ID (Android package name) as
535
     *                                  a string or {@see Model\AppId} object
536
     *
537
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
538
     *
539
     * @return Model\Permission[] an array of permissions for the application
540
     *
541
     * @api
542
     */
543 1
    public function getPermissions($appId): array
544
    {
545 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
546
547
        try {
548 1
            $request = Scraper\PlayStoreUiRequest::getPermissionsRequest($appId);
549
550
            /** @var Model\Permission[] $permissions */
551 1
            $permissions = $this->getHttpClient()->send(
552 1
                $request,
553
                [
554 1
                    Options::HANDLER_RESPONSE => new Scraper\PermissionScraper(),
555
                ]
556
            );
557
        } catch (\Throwable $e) {
558
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
559
        }
560
561 1
        return $permissions;
562
    }
563
564
    /**
565
     * Returns an array of application categories from the Google Play store.
566
     *
567
     * @throws Exception\GooglePlayException if HTTP error is received
568
     *
569
     * @return Model\Category[] array of application categories
570
     *
571
     * @api
572
     */
573 1
    public function getCategories(): array
574
    {
575 1
        $url = self::GOOGLE_PLAY_APPS_URL;
576
577
        try {
578
            /** @var Model\Category[] $categories */
579 1
            $categories = $this->getHttpClient()->request(
580 1
                'GET',
581 1
                $url,
582
                [
583 1
                    RequestOptions::QUERY => [
584 1
                        self::REQ_PARAM_LOCALE => $this->defaultLocale,
585
                    ],
586 1
                    Options::HANDLER_RESPONSE => new Scraper\CategoriesScraper(),
587
                ]
588
            );
589
        } catch (\Throwable $e) {
590
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
591
        }
592
593 1
        return $categories;
594
    }
595
596
    /**
597
     * Returns an array of application categories from the Google Play store for the specified locales.
598
     *
599
     * HTTP requests are executed in parallel.
600
     *
601
     * @param string[] $locales array of locales
602
     *
603
     * @throws Exception\GooglePlayException if HTTP error is received
604
     *
605
     * @return Model\Category[][] array of application categories by locale
606
     *
607
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
608
     *
609
     * @api
610
     */
611 2
    public function getCategoriesForLocales(array $locales): array
612
    {
613 2
        if (empty($locales)) {
614
            return [];
615
        }
616 2
        $locales = Util\LocaleHelper::getNormalizeLocales($locales);
617
618 2
        $urls = [];
619 2
        $url = self::GOOGLE_PLAY_APPS_URL;
620
621 2
        foreach ($locales as $locale) {
622 2
            $urls[$locale] = $url . '?' . http_build_query(
623
                [
624 2
                    self::REQ_PARAM_LOCALE => $locale,
625
                ]
626
            );
627
        }
628
629
        try {
630 2
            return $this->getHttpClient()->requestAsyncPool(
631 2
                'GET',
632 2
                $urls,
633
                [
634 2
                    Options::HANDLER_RESPONSE => new Scraper\CategoriesScraper(),
635
                ],
636 2
                $this->concurrency
637
            );
638
        } catch (\Throwable $e) {
639
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
640
        }
641
    }
642
643
    /**
644
     * Returns an array of categories from the Google Play store for all available locales.
645
     *
646
     * @throws Exception\GooglePlayException if HTTP error is received
647
     *
648
     * @return Model\Category[][] array of application categories by locale
649
     *
650
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
651
     *
652
     * @api
653
     */
654 1
    public function getCategoriesForAvailableLocales(): array
655
    {
656 1
        return $this->getCategoriesForLocales(Util\LocaleHelper::SUPPORTED_LOCALES);
657
    }
658
659
    /**
660
     * Returns information about the developer: name, icon, cover, description and website address.
661
     *
662
     * @param string|Model\Developer|Model\App $developerId developer id as
663
     *                                                      string, {@see Model\Developer}
664
     *                                                      or {@see Model\App} object
665
     *
666
     * @throws Exception\GooglePlayException if HTTP error is received
667
     *
668
     * @return Model\Developer information about the application developer
669
     *
670
     * @see GPlayApps::getDeveloperInfoForLocales() Returns information about the developer for the locale array.
671
     *
672
     * @api
673
     */
674 5
    public function getDeveloperInfo($developerId): Model\Developer
675
    {
676 5
        $developerId = Util\Caster::castToDeveloperId($developerId);
677
678 5
        if (!is_numeric($developerId)) {
679 3
            throw new Exception\GooglePlayException(
680 3
                sprintf(
681 3
                    'Developer "%s" does not have a personalized page on Google Play.',
682 3
                    $developerId
683
                )
684
            );
685
        }
686
687 2
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev';
688
689
        try {
690
            /** @var Model\Developer $developer */
691 2
            $developer = $this->getHttpClient()->request(
692 2
                'GET',
693 2
                $url,
694
                [
695 2
                    RequestOptions::QUERY => [
696 2
                        self::REQ_PARAM_ID => $developerId,
697 2
                        self::REQ_PARAM_LOCALE => $this->defaultLocale,
698
                    ],
699 2
                    Options::HANDLER_RESPONSE => new Scraper\DeveloperInfoScraper(),
700
                ]
701
            );
702 1
        } catch (\Throwable $e) {
703 1
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
704
        }
705
706 1
        return $developer;
707
    }
708
709
    /**
710
     * Returns information about the developer for the specified locales.
711
     *
712
     * @param string|Model\Developer|Model\App $developerId developer id as
713
     *                                                      string, {@see Model\Developer}
714
     *                                                      or {@see Model\App} object
715
     * @param string[]                         $locales     array of locales
716
     *
717
     * @throws Exception\GooglePlayException if HTTP error is received
718
     *
719
     * @return Model\Developer[] an array with information about the application developer
720
     *                           for each requested locale
721
     *
722
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
723
     * @see GPlayApps::getDeveloperInfo() Returns information about the developer: name,
724
     *     icon, cover, description and website address.
725
     *
726
     * @api
727
     */
728 1
    public function getDeveloperInfoForLocales($developerId, array $locales = []): array
729
    {
730 1
        if (empty($locales)) {
731
            return [];
732
        }
733 1
        $locales = Util\LocaleHelper::getNormalizeLocales($locales);
734
735 1
        $id = Util\Caster::castToDeveloperId($developerId);
736
737 1
        if (!is_numeric($id)) {
738
            throw new Exception\GooglePlayException(
739
                sprintf(
740
                    'Developer "%s" does not have a personalized page on Google Play.',
741
                    $id
742
                )
743
            );
744
        }
745
746 1
        $urls = [];
747 1
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev';
748
749 1
        foreach ($locales as $locale) {
750 1
            $urls[$locale] = $url . '?' . http_build_query(
751
                [
752 1
                    self::REQ_PARAM_ID => $id,
753 1
                    self::REQ_PARAM_LOCALE => $locale,
754
                ]
755
            );
756
        }
757
758
        try {
759 1
            return $this->getHttpClient()->requestAsyncPool(
760 1
                'GET',
761 1
                $urls,
762
                [
763 1
                    Options::HANDLER_RESPONSE => new Scraper\DeveloperInfoScraper(),
764
                ],
765 1
                $this->concurrency
766
            );
767
        } catch (\Throwable $e) {
768
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
769
        }
770
    }
771
772
    /**
773
     * Returns an array of applications from the Google Play store by developer id.
774
     *
775
     * @param string|Model\Developer|Model\App $developerId developer id as
776
     *                                                      string, {@see Model\Developer}
777
     *                                                      or {@see Model\App} object
778
     *
779
     * @throws Exception\GooglePlayException if HTTP error is received
780
     *
781
     * @return Model\App[] an array of applications with basic information
782
     *
783
     * @api
784
     */
785 1
    public function getDeveloperApps($developerId): array
786
    {
787 1
        $developerId = Util\Caster::castToDeveloperId($developerId);
788
789
        $query = [
790 1
            self::REQ_PARAM_ID => $developerId,
791 1
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
792 1
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
793
        ];
794
795 1
        if (is_numeric($developerId)) {
796 1
            $developerUrl = self::GOOGLE_PLAY_APPS_URL . '/dev?' . http_build_query($query);
797
798
            try {
799
                /**
800
                 * @var string|null $developerUrl
801
                 */
802 1
                $developerUrl = $this->getHttpClient()->request(
803 1
                    'GET',
804 1
                    $developerUrl,
805
                    [
806 1
                        Options::HANDLER_RESPONSE => new Scraper\FindDevAppsUrlScraper(),
807
                    ]
808
                );
809
810 1
                if ($developerUrl === null) {
811
                    return [];
812
                }
813 1
                $developerUrl .= '&' . self::REQ_PARAM_LOCALE . '=' . urlencode($this->defaultLocale) .
814 1
                    '&' . self::REQ_PARAM_COUNTRY . '=' . urlencode($this->defaultCountry);
815
            } catch (\Throwable $e) {
816 1
                throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
817
            }
818
        } else {
819 1
            $developerUrl = self::GOOGLE_PLAY_APPS_URL . '/developer?' . http_build_query($query);
820
        }
821
822 1
        return $this->fetchAppsFromClusterPage(
823 1
            $developerUrl,
824 1
            $this->defaultLocale,
825 1
            $this->defaultCountry,
826 1
            self::UNLIMIT
827
        );
828
    }
829
830
    /**
831
     * Returns a list of applications with basic information.
832
     *
833
     * @param string $clusterPageUrl cluster page URL
834
     * @param string $locale         locale
835
     * @param string $country        country
836
     * @param int    $limit          Maximum number of applications. To extract all
837
     *                               applications, use {@see GPlayApps::UNLIMIT}.
838
     *
839
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
840
     *
841
     * @return Model\App[] array of applications with basic information about them
842
     *
843
     * @see GPlayApps::UNLIMIT Limit for all available results.
844
     */
845 17
    protected function fetchAppsFromClusterPage(
846
        string $clusterPageUrl,
847
        string $locale,
848
        string $country,
849
        int $limit
850
    ): array {
851 17
        if ($limit < self::UNLIMIT || $limit === 0) {
852
            throw new \InvalidArgumentException(sprintf('Invalid limit: %d', $limit));
853
        }
854
855 17
        $clusterPageComponents = parse_url($clusterPageUrl);
856 17
        $query = parse_query($clusterPageComponents['query'] ?? '');
857 17
        $query[self::REQ_PARAM_LOCALE] = $locale;
858 17
        $query[self::REQ_PARAM_COUNTRY] = $country;
859
860 17
        $clusterPageUrl = $clusterPageComponents['scheme'] . '://' .
861 17
            $clusterPageComponents['host'] .
862 17
            $clusterPageComponents['path'] .
863 17
            '?' . build_query($query);
864
865
        try {
866 17
            [$apps, $token] = $this->getHttpClient()->request(
867 17
                'GET',
868 17
                $clusterPageUrl,
869
                [
870 17
                    Options::HANDLER_RESPONSE => new Scraper\ClusterAppsScraper(),
871
                ]
872
            );
873
874 17
            $allCount = \count($apps);
875 17
            $allApps = [$apps];
876
877 17
            while ($token !== null && ($limit === self::UNLIMIT || $allCount < $limit)) {
878 17
                $count = $limit === self::UNLIMIT ?
879 16
                    Scraper\PlayStoreUiRequest::LIMIT_APPS_ON_PAGE :
880 17
                    min(Scraper\PlayStoreUiRequest::LIMIT_APPS_ON_PAGE, max($limit - $allCount, 1));
881
882 17
                $request = Scraper\PlayStoreUiRequest::getAppsRequest($locale, $country, $count, $token);
883
884 17
                [$apps, $token] = $this->getHttpClient()->send(
885 17
                    $request,
886
                    [
887 17
                        Options::HANDLER_RESPONSE => new Scraper\PlayStoreUiAppsScraper(),
888
                    ]
889
                );
890 17
                $allCount += \count($apps);
891 17
                $allApps[] = $apps;
892
            }
893 3
        } catch (\Throwable $e) {
894 3
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
895
        }
896
897 17
        if (empty($allApps)) {
898
            return $allApps;
899
        }
900 17
        $allApps = array_merge(...$allApps);
901
902 17
        if ($limit !== self::UNLIMIT) {
903 1
            $allApps = \array_slice($allApps, 0, $limit);
904
        }
905
906 17
        return $allApps;
907
    }
908
909
    /**
910
     * Returns an array of similar applications with basic information about
911
     * them in the Google Play store.
912
     *
913
     * @param string|Model\AppId $appId application ID (Android package name)
914
     *                                  as a string or {@see Model\AppId} object
915
     * @param int                $limit The maximum number of similar applications.
916
     *                                  To extract all similar applications,
917
     *                                  use {@see GPlayApps::UNLIMIT}.
918
     *
919
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
920
     *
921
     * @return Model\App[] an array of applications with basic information about them
922
     *
923
     * @see GPlayApps::UNLIMIT Limit for all available results.
924
     *
925
     * @api
926
     */
927 1
    public function getSimilarApps($appId, int $limit = 50): array
928
    {
929 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
930
931
        try {
932
            /**
933
             * @var string|null $similarAppsUrl
934
             */
935 1
            $similarAppsUrl = $this->getHttpClient()->request(
936 1
                'GET',
937 1
                $appId->getFullUrl(),
938
                [
939 1
                    Options::HANDLER_RESPONSE => new Scraper\FindSimilarAppsUrlScraper($appId),
940
                ]
941
            );
942
943 1
            if ($similarAppsUrl === null) {
944
                return [];
945
            }
946
947 1
            return $this->fetchAppsFromClusterPage(
948 1
                $similarAppsUrl,
949 1
                $appId->getLocale(),
950 1
                $appId->getCountry(),
951 1
                $limit
952
            );
953
        } catch (\Throwable $e) {
954
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
955
        }
956
    }
957
958
    /**
959
     * Returns the Google Play search suggests.
960
     *
961
     * @param string $query search query
962
     *
963
     * @throws Exception\GooglePlayException if HTTP error is received
964
     *
965
     * @return string[] array containing search suggestions
966
     *
967
     * @api
968
     */
969 1
    public function getSearchSuggestions(string $query): array
970
    {
971 1
        $query = trim($query);
972
973 1
        if ($query === '') {
974
            return [];
975
        }
976
977 1
        $url = 'https://market.android.com/suggest/SuggRequest';
978
979
        try {
980
            /** @var string[] $suggestions */
981 1
            $suggestions = $this->getHttpClient()->request(
982 1
                'GET',
983 1
                $url,
984
                [
985 1
                    RequestOptions::QUERY => [
986 1
                        'json' => 1,
987 1
                        'c' => 3,
988 1
                        'query' => $query,
989 1
                        self::REQ_PARAM_LOCALE => $this->defaultLocale,
990 1
                        self::REQ_PARAM_COUNTRY => $this->defaultCountry,
991
                    ],
992 1
                    Options::HANDLER_RESPONSE => new Scraper\SuggestScraper(),
993
                ]
994
            );
995
        } catch (\Throwable $e) {
996
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
997
        }
998
999 1
        return $suggestions;
1000
    }
1001
1002
    /**
1003
     * Returns a list of applications from the Google Play store for a search query.
1004
     *
1005
     * @param string              $query search query
1006
     * @param int                 $limit the limit on the number of search results
1007
     * @param Enum\PriceEnum|null $price price category or `null`
1008
     *
1009
     * @throws Exception\GooglePlayException if HTTP error is received
1010
     *
1011
     * @return Model\App[] an array of applications with basic information
1012
     *
1013
     * @see Enum\PriceEnum Contains all valid values for the "price" parameter.
1014
     * @see GPlayApps::UNLIMIT Limit for all available results.
1015
     *
1016
     * @api
1017
     */
1018 1
    public function search(string $query, int $limit = 50, ?Enum\PriceEnum $price = null): array
1019
    {
1020 1
        $query = trim($query);
1021
1022 1
        if (empty($query)) {
1023
            throw new \InvalidArgumentException('Search query missing');
1024
        }
1025 1
        $price = $price ?? Enum\PriceEnum::ALL();
1026
1027
        $params = [
1028 1
            'c' => 'apps',
1029 1
            'q' => $query,
1030 1
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
1031 1
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
1032 1
            'price' => $price->value(),
1033
        ];
1034 1
        $clusterPageUrl = self::GOOGLE_PLAY_URL . '/store/search?' . http_build_query($params);
1035
1036 1
        return $this->fetchAppsFromClusterPage(
1037 1
            $clusterPageUrl,
1038 1
            $this->defaultLocale,
1039 1
            $this->defaultCountry,
1040 1
            $limit
1041
        );
1042
    }
1043
1044
    /**
1045
     * Returns an array of applications from the Google Play store for the specified category.
1046
     *
1047
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
1048
     *                                                               string, {@see Model\Category},
1049
     *                                                               {@see Enum\CategoryEnum} or
1050
     *                                                               `null` for all categories
1051
     * @param Enum\AgeEnum|null                            $age      age limit or null for no limit
1052
     * @param int                                          $limit    limit on the number of results
1053
     *                                                               or {@see GPlayApps::UNLIMIT}
1054
     *                                                               for no limit
1055
     *
1056
     * @return Model\App[] an array of applications with basic information
1057
     *
1058
     * @see GPlayApps::UNLIMIT Limit for all available results.
1059
     *
1060
     * @api
1061
     */
1062 5
    public function getListApps($category = null, ?Enum\AgeEnum $age = null, int $limit = self::UNLIMIT): array
1063
    {
1064 5
        return $this->fetchAppsFromClusterPages($category, $age, null, $limit);
1065
    }
1066
1067
    /**
1068
     * Returns an array of **top apps** from the Google Play store for the specified category.
1069
     *
1070
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
1071
     *                                                               string, {@see Model\Category},
1072
     *                                                               {@see Enum\CategoryEnum} or
1073
     *                                                               `null` for all categories
1074
     * @param Enum\AgeEnum|null                            $age      age limit or null for no limit
1075
     * @param int                                          $limit    limit on the number of results
1076
     *                                                               or {@see GPlayApps::UNLIMIT}
1077
     *                                                               for no limit
1078
     *
1079
     * @return Model\App[] an array of applications with basic information
1080
     *
1081
     * @see GPlayApps::UNLIMIT Limit for all available results.
1082
     *
1083
     * @api
1084
     */
1085 6
    public function getTopApps($category = null, ?Enum\AgeEnum $age = null, int $limit = self::UNLIMIT): array
1086
    {
1087 6
        return $this->fetchAppsFromClusterPages($category, $age, 'top', $limit);
1088
    }
1089
1090
    /**
1091
     * Returns an array of **new apps** from the Google Play store for the specified category.
1092
     *
1093
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
1094
     *                                                               string, {@see Model\Category},
1095
     *                                                               {@see Enum\CategoryEnum} or
1096
     *                                                               `null` for all categories
1097
     * @param Enum\AgeEnum|null                            $age      age limit or null for no limit
1098
     * @param int                                          $limit    limit on the number of results
1099
     *                                                               or {@see GPlayApps::UNLIMIT}
1100
     *                                                               for no limit
1101
     *
1102
     * @return Model\App[] an array of applications with basic information
1103
     *
1104
     * @see GPlayApps::UNLIMIT Limit for all available results.
1105
     *
1106
     * @api
1107
     */
1108 5
    public function getNewApps($category = null, ?Enum\AgeEnum $age = null, int $limit = self::UNLIMIT): array
1109
    {
1110 5
        return $this->fetchAppsFromClusterPages($category, $age, 'new', $limit);
1111
    }
1112
1113
    /**
1114
     * @param string|Model\Category|Enum\CategoryEnum|null $category
1115
     * @param Enum\AgeEnum|null                            $age
1116
     * @param string|null                                  $path
1117
     * @param int                                          $limit
1118
     *
1119
     * @return Model\App[]
1120
     */
1121 16
    protected function fetchAppsFromClusterPages($category, ?Enum\AgeEnum $age, ?string $path, int $limit): array
1122
    {
1123 16
        if ($limit === 0 || $limit < self::UNLIMIT) {
1124
            throw new \InvalidArgumentException('Negative limit');
1125
        }
1126
1127
        $queryParams = [
1128 16
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
1129 16
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
1130
        ];
1131
1132 16
        if ($age !== null) {
1133 1
            $queryParams['age'] = $age->value();
1134
        }
1135
1136 16
        $url = self::GOOGLE_PLAY_APPS_URL;
1137
1138 16
        if ($path !== null) {
1139 11
            $url .= '/' . $path;
1140
        }
1141
1142 16
        if ($category !== null) {
1143 12
            $url .= '/category/' . Util\Caster::castToCategoryId($category);
1144
        }
1145 16
        $url .= '?' . http_build_query($queryParams);
1146
1147
        /**
1148
         * @var array $categoryClusterPages = [[
1149
         *            "name" => "Top Free Games",
1150
         *            "url" => "https://play.google.com/store/apps/store/apps/collection/cluster?clp=......"
1151
         *            ]]
1152
         */
1153 16
        $categoryClusterPages = $this->getHttpClient()->request(
1154 16
            'GET',
1155 16
            $url,
1156
            [
1157 16
                Options::HANDLER_RESPONSE => new Scraper\ClusterPagesFromListAppsScraper(),
1158
            ]
1159
        );
1160
1161 16
        if (empty($categoryClusterPages)) {
1162 2
            return [];
1163
        }
1164
1165 14
        $iterator = new \ArrayIterator($categoryClusterPages);
1166 14
        $results = [];
1167
1168
        do {
1169 14
            $clusterPage = $iterator->current();
1170 14
            $clusterUrl = $clusterPage['url'];
1171
1172
            try {
1173 14
                $apps = $this->fetchAppsFromClusterPage($clusterUrl, $this->defaultLocale, $this->defaultCountry, self::UNLIMIT);
1174
1175 14
                foreach ($apps as $app) {
1176 14
                    if (!isset($results[$app->getId()])) {
1177 14
                        $results[$app->getId()] = $app;
1178
                    }
1179
                }
1180 3
            } catch (\Throwable $e) {
1181
                // ignore exception
1182
            }
1183
1184 14
            if ($limit !== self::UNLIMIT && \count($results) >= $limit) {
1185 1
                $results = \array_slice($results, 0, $limit);
1186 1
                break;
1187
            }
1188
1189 13
            $iterator->next();
1190 13
        } while ($iterator->valid());
1191
1192 14
        return $results;
1193
    }
1194
1195
    /**
1196
     * Asynchronously saves images from googleusercontent.com and similar URLs to disk.
1197
     *
1198
     * Before use, you can set the parameters of the width-height of images.
1199
     *
1200
     * Example:
1201
     * ```php
1202
     * $gplay->saveGoogleImages(
1203
     *     $images,
1204
     *     static function (\Nelexa\GPlay\Model\GoogleImage $image): string {
1205
     *         $hash = $image->getHashUrl($hashAlgo = 'md5', $parts = 2, $partLength = 2);
1206
     *         return 'path/to/screenshots/' . $hash . '.{ext}';
1207
     *     },
1208
     *     $overwrite = false
1209
     * );
1210
     * ```
1211
     *
1212
     * @param Model\GoogleImage[] $images           array of {@see Model\GoogleImage} objects
1213
     * @param callable            $destPathCallback The function to which the
1214
     *                                              {@see Model\GoogleImage} object is
1215
     *                                              passed and you must return the full
1216
     *                                              output. path to save this file.
1217
     * @param bool                $overwrite        overwrite files if exists
1218
     *
1219
     * @return Model\ImageInfo[] returns an array with information about saved images
1220
     *
1221
     * @see Model\GoogleImage Contains a link to the image, allows you to customize its size and download it.
1222
     * @see Model\ImageInfo Contains information about the image.
1223
     *
1224
     * @api
1225
     */
1226 2
    public function saveGoogleImages(
1227
        array $images,
1228
        callable $destPathCallback,
1229
        bool $overwrite = false
1230
    ): array {
1231
        /** @var array<string, StreamInterface> $mapping */
1232 2
        $mapping = [];
1233
1234 2
        foreach ($images as $image) {
1235 2
            if (!$image instanceof Model\GoogleImage) {
1236
                throw new \InvalidArgumentException(
1237
                    'An array of ' . Model\GoogleImage::class . ' objects is expected.'
1238
                );
1239
            }
1240 2
            $destPath = $destPathCallback($image);
1241 2
            $url = $image->getUrl();
1242 2
            $mapping[$url] = new Util\LazyStream($destPath, 'w+b');
1243
        }
1244
1245 2
        $httpClient = $this->getHttpClient();
1246
        $promises = (static function () use ($mapping, $overwrite, $httpClient) {
1247 2
            foreach ($mapping as $url => $stream) {
1248 2
                $destPath = $stream->getFilename();
1249 2
                $dynamicPath = strpos($destPath, '{url}') !== false;
1250
1251 2
                if (!$overwrite && !$dynamicPath && is_file($destPath)) {
1252
                    yield $url => new FulfilledPromise($url);
1253
                } else {
1254
                    yield $url => $httpClient
1255 2
                        ->requestAsync(
1256 2
                            'GET',
1257 2
                            $url,
1258
                            [
1259 2
                                RequestOptions::COOKIES => null,
1260 2
                                RequestOptions::SINK => $stream,
1261 2
                                RequestOptions::HTTP_ERRORS => true,
1262
                                RequestOptions::ON_HEADERS => static function (ResponseInterface $response) use (
1263 2
                                    $url,
1264 2
                                    $stream
1265
                                ): void {
1266 2
                                    Model\GoogleImage::onHeaders($response, $url, $stream);
1267 2
                                },
1268
                            ]
1269
                        )
1270 2
                        ->then(
1271
                            static function (
1272
                                /** @noinspection PhpUnusedParameterInspection */
1273
                                ResponseInterface $response
1274
                            ) use ($url) {
1275 1
                                return $url;
1276 2
                            }
1277
                        )
1278
                    ;
1279
                }
1280
            }
1281 2
        })();
1282
1283
        /**
1284
         * @var Model\ImageInfo[] $imageInfoList
1285
         */
1286 2
        $imageInfoList = [];
1287 2
        (new EachPromise(
1288 2
            $promises,
1289
            [
1290 2
                'concurrency' => $this->concurrency,
1291
                'fulfilled' => static function (string $url) use (&$imageInfoList, $mapping): void {
1292 1
                    $imageInfoList[] = new Model\ImageInfo($url, $mapping[$url]->getFilename());
1293 2
                },
1294
                'rejected' => static function (\Throwable $reason, string $exceptionUrl) use ($mapping): void {
1295 1
                    foreach ($mapping as $destPath => $url) {
1296 1
                        if (is_file($destPath)) {
1297 1
                            unlink($destPath);
1298
                        }
1299
                    }
1300
1301 1
                    throw (new Exception\GooglePlayException(
1302 1
                        $reason->getMessage(),
1303 1
                        $reason->getCode(),
1304 1
                        $reason
1305 1
                    ))->setUrl(
1306 1
                        $exceptionUrl
1307
                    );
1308 2
                },
1309
            ]
1310 2
        ))->promise()->wait();
1311
1312 1
        return $imageInfoList;
1313
    }
1314
1315
    /**
1316
     * Returns the locale (language) of the requests.
1317
     *
1318
     * @return string locale (language) for HTTP requests to Google Play
1319
     */
1320 5
    public function getDefaultLocale(): string
1321
    {
1322 5
        return $this->defaultLocale;
1323
    }
1324
1325
    /**
1326
     * Sets the locale (language) of requests.
1327
     *
1328
     * @param string $defaultLocale locale (language) for HTTP requests to Google Play
1329
     *
1330
     * @return GPlayApps returns the current class instance to allow method chaining
1331
     */
1332 70
    public function setDefaultLocale(string $defaultLocale): self
1333
    {
1334 70
        $this->defaultLocale = Util\LocaleHelper::getNormalizeLocale($defaultLocale);
1335
1336 70
        return $this;
1337
    }
1338
1339
    /**
1340
     * Returns the country of the requests.
1341
     *
1342
     * @return string country for HTTP requests to Google Play
1343
     */
1344 5
    public function getDefaultCountry(): string
1345
    {
1346 5
        return $this->defaultCountry;
1347
    }
1348
1349
    /**
1350
     * Sets the country of requests.
1351
     *
1352
     * @param string $defaultCountry country for HTTP requests to Google Play
1353
     *
1354
     * @return GPlayApps returns the current class instance to allow method chaining
1355
     */
1356 70
    public function setDefaultCountry(string $defaultCountry): self
1357
    {
1358 70
        $this->defaultCountry = !empty($defaultCountry) ?
1359 70
            $defaultCountry :
1360 2
            self::DEFAULT_COUNTRY;
1361
1362 70
        return $this;
1363
    }
1364
1365
    /**
1366
     * Sets the number of seconds to wait when trying to connect to the server.
1367
     *
1368
     * @param float $connectTimeout Connection timeout in seconds, for example 3.14. Use 0 to wait indefinitely.
1369
     *
1370
     * @return GPlayApps returns the current class instance to allow method chaining
1371
     */
1372
    public function setConnectTimeout(float $connectTimeout): self
1373
    {
1374
        $this->getHttpClient()->setConnectTimeout($connectTimeout);
1375
1376
        return $this;
1377
    }
1378
1379
    /**
1380
     * Sets the timeout of the request in second.
1381
     *
1382
     * @param float $timeout Waiting timeout in seconds, for example 3.14. Use 0 to wait indefinitely.
1383
     *
1384
     * @return GPlayApps returns the current class instance to allow method chaining
1385
     */
1386
    public function setTimeout(float $timeout): self
1387
    {
1388
        $this->getHttpClient()->setTimeout($timeout);
1389
1390
        return $this;
1391
    }
1392
}
1393