Свой собственный стор v0.0.1

25 minute read

На первый взгляд, это не выглядит разумной идеей. Делать свой магазин, когда существует Google Play и AppStore, как минимум странно. Но я могу назвать 4 причины, когда это может быть оправдано.

Во-первых, современные магазины приложений полностью контролируются корпорациями. Это ограничивает ваши возможности и увеличивает риски. Если компании захотят убрать какие-то приложения из своего стора, то им никто не помешает.

Во-вторых, свой собственный стор может быть интересен как часть корпоративной инфраструктуры. Если работа вашего предприятия завязана на определенных приложениях, то разумно собрать их в одно контролируемое место. При этом, на девайсах сотрудников может быть отключена функция установки приложений из других источников.

Вы можете помочь своим стареньким родственникам, собрать приложения для них и контролировать что они будут устанавливать на свои девайсы. У вас будет возможность упростить интерфейс до самого минимума и оставить только необходимый функционал.

В вашем сторе могут быть приложения, которые не пропустили в другой стор. Например, kinopub. Или приложения различных торрент трекеров. Там образом, вы внесете свою долю в борьбу за свободный интренет.

Подготовка

Определимся, что мы хоти получить в итоге. Это точно будет приложение, с помощью которого можно будет устанавливать другие приложения.

Окей, как это приложение будет работать? Интерфейс сделаем на Flutter, для нативной логики будем использовать Kotlin. Приложение будет скачивать APK файлы через наше API с какого нибудь AWS. API напишем на самом подходящем для этого языке - Go.

Важный момент - где брать сами приложения? Еще и в виде тех самых APK файлов? Конечно, если хорошо поискать, то часть приложений доступна по прямым ссылкам (например, на сайтах самих разработчиков), но бОльшая часть есть только в Google Play. А это значит, что нам нужен парсер для Google Play.

Приступаем

И так, вся наша “экосистема” будет состоять из трех частей: приложение для пользователя, API для приложения и парсера APK файлов с Google Play.

Приложение

На Flutter интерфейс для магазина делается буквально за часы. Я не буду описывать как сделать запросы в сеть и показать список доступных приложений на экране. Об этом уже очень много написано в интернете. Остановлюсь только на интересных местах.

Для управления стейтом я использовал provider совместно со state_notifier. Это максимально простой и удобный подход к менеджементу состояния в Flutter приложении.

А дальше уже интересней. Перед тем как что-то устанавливать, нужно что-то скачать. Для тестов можно выложить APK на любой сервер, чтоб файл стал доступен через интернет. Теперь скачиваем его в приложении:

 1void download(String url, {Function? success, Function? error}) async {
 2    var name = basename(url);
 3    var request = http.Request('GET', Uri.parse(url));
 4    var response = client.send(request);
 5    String dir = (await getApplicationDocumentsDirectory()).path;
 6
 7    List<List<int>> chunks = [];
 8    int downloaded = 0;
 9
10    response.asStream().listen((http.StreamedResponse r) {
11        r.stream.listen((List<int> chunk) {
12                print('downloaded: ${downloaded * Megabyte}');
13
14                chunks.add(chunk);
15                downloaded += chunk.length;
16            }, onDone: () async {
17                print('downloaded: ${downloaded * Megabyte}');
18
19                var path = '$dir/$name';
20
21                File file = new File(path);
22                final Uint8List bytes = Uint8List(downloaded);
23                int offset = 0;
24                for (List<int> chunk in chunks) {
25                    bytes.setRange(offset, offset + chunk.length, chunk);
26                    offset += chunk.length;
27                }
28
29                await file.writeAsBytes(bytes);
30
31                if (success != null) {
32                    success(path);
33                }
34
35                return;
36            }, onError: (err) async {
37                if (error != null) {
38                    error();
39                }
40        });
41    });
42}

Функция getApplicationDocumentsDirectory() возвращает путь к директории, куда я собираюсь скачать APK файл. С помощью basename(url) получаю имя файла. Для запуска процесса скачивания в фоне использую функционал стримов response.asStream() и по завершению скачивания вызываю коллбек success(path);

Отлично, файл у нас, можно устанавливать. И тут я буду использовать часть нативного кода на Kotlin. Для вызова нативного кода установлю пакет pigeon и создам необходимые контракты в файле pideons/install/apk.dart

 1import 'package:pigeon/pigeon.dart';
 2
 3
 4class InstallRequest {
 5  String? file;
 6}
 7
 8class InstallResponse {
 9  bool? status;
10}
11
12@HostApi()
13abstract class InstallApk {
14  @async
15  InstallResponse install(InstallRequest request);
16}

Из этого конктракта необходимо сгенерировать интерфейсы для нативного кода и для Dart

1flutter pub run pigeon \
2  --input pigeons/install/apk.dart \
3  --dart_out lib/pigeons/install/apk.dart \
4  --java_out ./android/app/src/main/java/ru/kovardin/getapp/pigeons/install/Apk.java \
5  --java_package "ru.kovardin.getapp.pigeons.install"

pigeon генерирует Java фалы, но мы можем использовать в нативном коде на Kotlin. Теперь необходимо поддержать интерфейс InstallApk, объявленные в сгенерированном Java коде

 1package ru.kovardin.getapp.install
 2
 3import android.app.Activity
 4import android.content.Intent
 5import android.content.pm.PackageManager
 6import android.content.pm.ResolveInfo
 7import android.net.Uri
 8import android.os.Build
 9import android.util.Log
10import androidx.core.content.FileProvider
11import ru.kovardin.getapp.pigeons.install.Apk
12import java.io.File
13
14
15open class Apk(val context: Activity): Apk.InstallApk {
16    override fun install(request: Apk.InstallRequest, result: Apk.Result<Apk.InstallResponse>?) {
17        Log.d("APK", "install apk ${request.file}")
18
19        try {
20            val file = File(request.file ?: "")
21            val intent = Intent(Intent.ACTION_VIEW)
22            val authority =  context.getApplicationContext().getPackageName().toString() + ".provider"
23            val type = "application/vnd.android.package-archive"
24
25            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
26                val apk: Uri = FileProvider.getUriForFile(
27                        context,
28                        authority,
29                        file
30                )
31
32                intent.setDataAndType(apk, type)
33                val infos: List<ResolveInfo> = context.getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
34                for (info in infos) {
35                    context.grantUriPermission(authority, apk, Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
36                }
37                intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
38                intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
39
40            } else {
41                intent.setAction(Intent.ACTION_VIEW)
42                intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
43                intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
44                intent.setDataAndType(Uri.fromFile(file), type)
45            }
46            context.startActivity(intent)
47        } catch (e: Exception) {
48            e.printStackTrace()
49        }
50
51        val response = Apk.InstallResponse();
52        response.status = true;
53        result?.success(response)
54    }
55}

Код выглядит монструозно, но тут нет ничего сложного. Установка приложения - это запуск интента с указанием правильного типа и APK файлом в параметрах. Дальше андроид все сделает за нас.

Сначала я проверяю версию SDK. От версии зависит логика, по которой мы будем работать со скаченным APK файлом. Для версии SDK 23 и ниже можно использовать обычный доступ к файлу по его пути: Uri.fromFile(file). Этот код выполняется в секции else

1intent.setAction(Intent.ACTION_VIEW)
2intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
3intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
4intent.setDataAndType(Uri.fromFile(file), type)

Для версии SDK 24 и выше работать со скаченными файлами чуть сложнее. Нужно добавить отдельную секцию в файл AndroidManifest.xml

 1<application
 2        android:allowBackup="true"
 3        android:label="@string/app_name">
 4        <provider
 5            android:name="android.support.v4.content.FileProvider"
 6            android:authorities="${applicationId}.authorityStr"
 7            android:exported="false"
 8            android:grantUriPermissions="true">
 9            <meta-data
10                android:name="android.support.FILE_PROVIDER_PATHS"
11                android:resource="@xml/paths"/>
12        </provider>
13</application>

Тут @xml/paths это отдельный ресурсный файл paths.xml из папки xml. Необходимо задать его содержимое:

 1<?xml version="1.0" encoding="utf-8"?>
 2<paths xmlns:android="http://schemas.android.com/apk/res/android">
 3    <external-path
 4            name="files_root"
 5            path="Android/data/${applicationId}" />
 6    <external-path
 7            name="external_files"
 8            path="." />
 9    <root-path name="root" path="/data/" />
10</paths>

Подробно про работу с файлами в андроид SDK версии 24 и выше можно почитать в документации.

Теперь можно вызывать нативный код из Dart кода. Pigeon сгенерировал класс InstallApk, который я буду использовать в сервисе.

1final apk = InstallApk();
2final response = await apk.install(InstallRequest(
3    file: apk,
4));

Еще один важный момент. Перед тем как устанавливать APK, необходимо запросить разрешения у пользователя. Это легко делается с помощью permission_handler

1Permission.storage.request().then((value) {
2    print(value);
3
4    Permission.requestInstallPackages.request().then((value) {
5        print(value);
6    });
7});

Все необходимые части для работы приложения собраны, можно переходить к бэкендовой части.

API

API проще всего написать на Go, он идеально подходит для этих целей. Чтобы работать с http я буду использовать chi. Это простой но функциональный роутер.

 1func (a *Api) Route(r chi.Router) {
 2	r.Route("/v1", func(r chi.Router) {
 3
 4		r.Route("/apps", func(r chi.Router) {
 5			r.Route("/{bundle}", func(r chi.Router) {
 6				r.Get("/", a.apps.One)
 7				r.Get("/download", a.apps.Download)
 8			})
 9			r.Get("/search", a.apps.Search)
10			r.Post("/updates", a.apps.Updates)
11		})
12
13        r.Get("/file/*", a.static.File)
14
15		// ...
16
17	})
18}

Все APK файлы хранятся на AWS. Чтобы правильно их раздавать, в сервисе нужен хендлер, который будет проксировать запросы к файлам и добавлять специальный заголовок headers.Set("Content-Type", "application/vnd.android.package-archive"). Это заголовок нужен для скачивания файлов именно как .apk. Без этого заголовка на некоторых андроид устройствах файлы будут скачиваться как .zip архивы.

 1func (h Static) File(w http.ResponseWriter, r *http.Request) {
 2	headers := http.Header{}
 3
 4	ext := path.Ext(r.URL.Path)
 5	if ext == ".apk" {
 6		headers.Set("Content-Type", "application/vnd.android.package-archive")
 7	}
 8
 9	proxy := httputil.ReverseProxy{
10		Director:  func(r *http.Request) {},
11		Transport: responseHeadersTransport(headers),
12	}
13
14	target := h.config.Scheme + path.Join(h.config.Cloud, strings.Replace(r.URL.Path, "file/", "", 1))
15	req, _ := http.NewRequest("GET", target, nil)
16	proxy.ServeHTTP(w, req)
17}

Все остальное в API максимально шаблонное.

Парсер

Чтобы сделать магазин приложений нужны приложения. И проще всего их взять из уже готового магазина. Да, поднимаем пиратский флаг и парсим Google Play. Все исключительно для ознакомления.

И тут мне повезло. На гитхабе есть готовая либа для скачивания APK файлов - googleplay.

Для скачивания файлов необходимо залогниться в Google Play

googleplay -email EMAIL -password PASSWORD

Эта команда сгенерирует специальный токен файл, который будет использоваться для запросов. Дальше необходимо сгенерировать файл с идентификатором девайся, от имени которого будут происходить запросы:

googleplay -device

После всех манипуляций можно получить информацию по приложению:

> googleplay -a com.google.android.youtube
Title: YouTube
Creator: Google LLC
UploadDate: 2022-05-12
VersionString: 17.19.34
VersionCode: 1529337280
NumDownloads: 11.822 B
Size: 46.727 MB
File: APK APK APK APK
Offer: 0 USD

И скачивать конкретную версию любого приложения:

googleplay -a com.google.android.youtube -v 1529337280

Кстати, от этого разработчика есть отличный пакет protobuf, который позволяет разбирать кодированный протобаф без протофайлов.

Что получается в итоге

Написать свой магазин приложений не так сложно как кажется. На самом деле, почти всю работу за нас делает сам андроид. От нас нужно правильно скачать apk файлы, получить от пользователя все разрешения и запустить стандартную для андроида установку.

Кончено, это только минимальный функционал. С помощью firebase_messaging можно добавить пуш уведомления, а пакет installed_apps поможет реализовать логику обновлений.

comments powered by Disqus