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

Итак, начнем

Зависимости

Для работы понадобится Retforit:

implementation ("com.squareup.retrofit2:retrofit:2.11.0")
implementation ("com.squareup.retrofit2:converter-gson:2.11.0")

Для того, чтобы сделать автообновление приложения, нужно знать, что обновление есть, и знать, что качать.

На просторах интернета немало информации про GitHub API, поэтому я расскажу вкратце. Поинт, по которому можно получить информацию о последнем релизе, выглядит следующим образом: https://api.github.com/repos/{user}/{repository}/releases/latest, где user и repository говорят сами за себя. Этот поинт предоставляет огромное количество информации (К сожалению, качество оставляет желать лучшего):

Все, что нам понадобится из этого, - tag_name и название apk-файла - name - в assets. Получить это в Android приложении довольно просто можно через Retrofit.

Retrofit сервис для получения данных о релизе

Для начала добавим в AndroidManifest разрешение на использование интернета:

<uses-permission android:name="android.permission.INTERNET" />

Сервис выглядит как и все стандартные сервисы Retrofit, нам нужен лишь один метод GET:

interface GitHubDataService {
  
    @GET("repos/vafeen/UniversitySchedule/releases/latest")
    suspend fun getLatestRelease(): Response<Release>
  
}

GsonConverterFactory здесь нужен для автоматического конвертирования ответов в нужные данные. Мы будем конвертировать в класс Release, который исходя из данных на поинте будет выглядеть вот так:

data class Release(
    val url: String,
    @SerializedName("assets_url") val assetsUrl: String,
    @SerializedName("upload_url") val uploadUrl: String,
    @SerializedName("html_url") val htmlUrl: String,
    val id: Long,
    val author: Author,
    @SerializedName("node_id") val nodeId: String,
    @SerializedName("tag_name") val tagName: String,
    @SerializedName("target_commitish") val targetCommitish: String,
    val name: String,
    val draft: Boolean,
    @SerializedName("prerelease") val preRelease: Boolean,
    @SerializedName("created_at") val createdAt: String,
    @SerializedName("published_at") val publishedAt: String,
    val assets: List<Asset>,
    @SerializedName("tarball_url") val tarballUrl: String,
    @SerializedName("zipball_url") val zipballUrl: String,
    val body: String
)

Внутри него также используются классы Author и Asset:

data class Author(
    val login: String,
    val id: Long,
    @SerializedName("node_id") val nodeId: String,
    @SerializedName("avatar_url") val avatarUrl: String,
    @SerializedName("gravatar_id") val gravatarId: String,
    val url: String,
    @SerializedName("html_url") val htmlUrl: String,
    @SerializedName("followers_url") val followersUrl: String,
    @SerializedName("following_url") val followingUrl: String,
    @SerializedName("gists_url") val gistsUrl: String,
    @SerializedName("starred_url") val starredUrl: String,
    @SerializedName("subscriptions_url") val subscriptionsUrl: String,
    @SerializedName("organizations_url") val organizationsUrl: String,
    @SerializedName("repos_url") val reposUrl: String,
    @SerializedName("events_url") val eventsUrl: String,
    @SerializedName("received_events_url") val receivedEventsUrl: String,
    val type: String,
    @SerializedName("site_admin") val siteAdmin: Boolean
)
data class Asset(
    val url: String,
    val id: Long,
    @SerializedName("node_id") val nodeId: String,
    val name: String,
    val label: String?,
    val uploader: Author,
    @SerializedName("content_type") val contentType: String,
    val state: String,
    val size: Long,
    @SerializedName("download_count") val downloadCount: Int,
    @SerializedName("created_at") val createdAt: String,
    @SerializedName("updated_at") val updatedAt: String,
    @SerializedName("browser_download_url") val browserDownloadUrl: String
)

Поскольку названия полей здесь являются названиями ключей в Json, используем SerializedName аннотацию для переопределения.

Сетевой репозиторий

Для удобной работы через сетевой репозиторий будем инжектить его через Hilt. Начнем с модуля:

@Module
@InstallIn(SingletonComponent::class)
class RetrofitDIModule {

    @Provides
    @Singleton
    fun provideGHDService(): GitHubDataService = Retrofit.Builder()
        .baseUrl("https://api.github.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build().create(GitHubDataService::class.java)
}

Сам сетевой репозиторий - обычный класс, который будет выглядеть следующим образом (Да простит меня Роберт Мартин за отсутствие абстракций, но я упростил, чтобы не загромождать статью):

class NetworkRepository @Inject constructor(
    private val gitHubDataService: GitHubDataService
) {

    suspend fun getLatestRelease(): Response<Release>? = try {
        gitHubDataService.getLatestRelease()
    } catch (e: Exception) {
        null
    }
}

getLatestRelease оборачивается в блок try {} catch {}, поскольку при получении данных из сети может возникнуть огромное количество ошибок, такие как: долгое ожидание, отсутствие или неожиданное отключение интернета и много другое.

Проверка версии приложения

На данном этапе, когда мы имеем версию последнего релиза, нужно узнать, требуется ли приложению обновление. Тэги общепринято называть по шаблону "v"+`номер версии`. В примере у последнего релиза версия 1.3, т.к. tag_name == v1.3, значит, чтобы приложение было актуальным, нужно, чтобы его versionName, который указывается в Gradle, совпадал с названием версии в последнем тэге.

Программно узнать версию приложения можно следующим образом:

fun getVersionName(context: Context): String? =
    context.packageManager.getPackageInfo(context.packageName, 0).versionName

Раньше versionName возвращало String, но в последних версиях Android студия заставляет указать нуллабельный тип, но если версия указывается в каждом релизе, беспокоиться не о чем, она здесь вернется.

Полная проверка с получением данных будет выглядеть так:

val versionName = getVersionName(context = context)

Корутина {
val release = networkRepository.getLatestRelease()?.body()

if (release != null && versionName != null &&
            release?.tag_name?.substringAfter("v") != versionName) {

//                  Обновление приложения
  
            }
}

Обновление приложения

Предполагается, что текущая версия приложения ниже версии последнего релиза, а последняя версия добавлена в GitHub releases.

Обновление приложения будет включать в себя скачивание APK-файла и запрос на его установку пользователю.

Скачивание APK-файла

Для скачивания файла нужен еще один Retrofit-сервис

interface DownloadService {
  
    @GET
    @Streaming
    fun downloadFile(@Url fileUrl: String): Call<ResponseBody>
  
}

Метод - GET

Streaming - эта аннотация указывает Retrofit, что ответ от сервера должен быть обработан по мере поступления данных, а не загружен полностью в память. (Далее важно будет обрабатывать прогресс, поскольку если просто подписаться на этот стрим, интерфейс зависнет от количества операций)

Класс Сall представляет собой HTTP запрос, который можно выполнить асинхронно или синхронно. Он предоставляет методы для выполнения запроса и обработки ответа.

ResponseBody нужен для получения тела ответа.

Также добавим реализацию этого интерфейса в RetrofitDIModule и конструктор репозитория

@Provides
@Singleton
fun provideDownloadService(): DownloadService = Retrofit.Builder()
        .baseUrl("https://github.com/")
        .build().create(DownloadService::class.java)

Скачивание и установка файла

Скачивание

Код скачивания файла я нашел в интернете по ссылке https://github.com/mt-ks/kotlin-file-download но немного упростил его и добавил обработку ошибок.

Скачивать и устанавливать файл я буду через Singleton Hilt, чтобы не потерять данные в потоках.

Объяснять, как работает этот код долго, поэтому я оставлю комментарии:


/**
 * Синглтон для загрузки и установки APK-файла.
 * Предоставляет потоки для отслеживания прогресса загрузки и статуса процесса обновления.
 *
 * @property networkRepository репозиторий для выполнения сетевых запросов (должен предоставлять Call<ResponseBody>)
 * @property context контекст приложения (предоставляется Dagger)
 */
@Singleton
class Downloader @Inject constructor(
    private val networkRepository: NetworkRepository,
    private val context: Context
) {
    // Поток для передачи прогресса загрузки (значения от 0.0 до 1.0)
    private val _percentageFlow = MutableSharedFlow<Float>()
    val percentageFlow = _percentageFlow.asSharedFlow()

    // Поток для индикации выполнения процесса обновления (true – идёт загрузка/установка)
    private val _isUpdateInProcessFlow = MutableSharedFlow<Boolean>()
    val isUpdateInProcessFlow = _isUpdateInProcessFlow.asSharedFlow()

    /**
     * Устанавливает APK-файл через системный установщик.
     * Использует FileProvider для получения URI с правами на чтение.
     *
     * @param apkFilePath путь к скачанному APK-файлу
     */
    private fun installApk(apkFilePath: String) {
        val file = File(apkFilePath)

        if (file.exists()) {
            val intent = Intent(Intent.ACTION_VIEW).apply {
                setDataAndType(
                    FileProvider.getUriForFile(
                        context,
                        "${context.packageName}.provider", // authority, определённый в манифесте
                        file
                    ),
                    "application/vnd.android.package-archive"
                )
                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            }
            context.startActivity(intent)
        } else {
            Log.e("Downloader", "APK файл не найден: $apkFilePath")
        }
    }

    /**
     * Загружает APK по указанному URL и запускает установку.
     * Метод является suspend, вся работа выполняется в потоке Dispatchers.IO.
     *
     * @param url URL для загрузки APK-файла
     * @param downloadedFileName имя, под которым сохранить файл (по умолчанию "app-release.apk")
     * @return true, если загрузка и установка прошли успешно, иначе false
     */
    suspend fun downloadApk(
        url: String,
        downloadedFileName: String = "app-release.apk"
    ): Boolean = withContext(Dispatchers.IO) {
        // Путь для сохранения файла
        val apkFilePath = context.pathToDownloadFile(downloadedFileName)

        try {
            // Сигнализируем о начале процесса обновления
            _isUpdateInProcessFlow.emit(true)

            // Выполняем синхронный запрос на загрузку
            val call: Call<ResponseBody>? = networkRepository.downloadFile(url)
            val response = call?.execute() ?: throw Exception("Не удалось создать вызов для загрузки")
            val body = response.body()

            if (response.isSuccessful && body != null) {
                val file = File(apkFilePath)
                val inputStream = body.byteStream()
                val outputStream = FileOutputStream(file)
                val buffer = ByteArray(8 * 1024) // 8 КБ буфер
                var bytesRead: Int
                var totalBytesRead = 0L
                val contentLength = body.contentLength()
                var lastPercentage = 0

                // Чтение и запись данных с отслеживанием прогресса
                while (inputStream.read(buffer).also { bytesRead = it } != -1) {
                    outputStream.write(buffer, 0, bytesRead)
                    totalBytesRead += bytesRead

                    // Вычисляем процент загрузки (целое число от 0 до 100)
                    val currentPercentage = (totalBytesRead * 100 / contentLength).toInt()
        
                    // Отправляем прогресс только при изменении процента, чтобы сократить количество эмитов
                    if (currentPercentage != lastPercentage) {
                        lastPercentage = currentPercentage
                        _percentageFlow.emit(currentPercentage / 100f) // конвертируем в Float от 0 до 1
                    }
                }

                // Закрываем потоки
                outputStream.close()
                inputStream.close()

                // Завершаем процесс загрузки
                _isUpdateInProcessFlow.emit(false)

                // Запускаем установку APK
                installApk(apkFilePath)

                return@withContext true
            } else {
                throw Exception("Ошибка сервера: ${response.code()}")
            }
        } catch (e: UnknownHostException) {
            Log.e("Downloader", "Нет интернет-соединения: ${e.localizedMessage}")
            _percentageFlow.emit(0f)
            _isUpdateInProcessFlow.emit(false)
        } catch (e: IOException) {
            Log.e("Downloader", "Сетевая ошибка: ${e.localizedMessage}")
            _percentageFlow.emit(0f)
            _isUpdateInProcessFlow.emit(false)
        } catch (e: Exception) {
            Log.e("Downloader", "Ошибка загрузки: ${e.localizedMessage}")
            _percentageFlow.emit(0f)
            _isUpdateInProcessFlow.emit(false)
        }

        return@withContext false
    }
}

internal fun Context.pathToDownloadFile(fileName: String): String =
    "${externalCacheDir?.absolutePath}/$fileName"

percentageFlow и isUpdateInProcessFlow нужно для того, чтобы подписываться на эти Flow на экранах и соответствующе обновлять интерфейс. В конце статьи я покажу на видео, как я это делал. Здесь используется SharedFlow, поскольку от обычного Flow оно отличается тем, что SharedFlow хранит в себе всю историю изменения и ведет запись даже при отсутствии подписчиков, а при их появлении просто транслирует все данные, а обычное Flow ведет запись только когда на него подписываются.

В "Отправляем процент загрузки" я отправляю класс тип данных Float и в виде числа от 0 до 1, поскольку в ProgressBar обычно используется именно такой диапазон для отображения прогресса, а пользователю уже отображаю в процентах.

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

Здесь начинаем скачивание и downloader уведомляет isUpdateInProcessFlow о начале скачивания.

val versionName = getVersionName(context = context)

val release = networkRepository.getLatestRelease()?.body()

if (release != null && versionName != null &&
            release?.tag_name?.substringAfter("v") != versionName) {
                         downloader.downloadApk(
                            context = context,
                            networkRepository = networkRepository,
                            url = "vafeen/UniversitySchedule/releases/download/${release.tagName}/${release.assets[0].name}",
                        )
}

Я в своем приложении подписываю на этот поток показ полосы загрузки и процента для отображения пользователю, а дальше, когда процесс заканчивается, в isUpdateInProcess приходит false и скрывается показ скачивания.

    val isUpdateInProcess by viewModel.isUpdateInProcessFlow.collectAsState(false)
    val downloadedPercentage by viewModel.percentageFlow.collectAsState(0f)

(Или collect {} для Views проекта)

Установка

Для создания запросов на установку пакетов приложению нужно разрешение:

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

В манифесте внутри <application> следует указать следующий код для настройки FileProvider

<provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.provider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
</provider>

и @xml/file_paths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-cache-path
        name="external_cache"
        path="." />
</paths>

FileProvider позволяет безопасно передавать файлы между приложениями, предоставляя временные URI, которые можно использовать для доступа к файлам.

В данном случае происходит запуск установщика с установочным файлом по URI.

Установка, которую следует добавить в Downloader:

private fun installApk(context: Context) {

  // местоположение скачанного файла 
        val apkFilePath = "${context.externalCacheDir?.absolutePath}/app-release.apk"
        
  // Создаем объект File для APK файла по указанному пути
        val file = File(apkFilePath)

        // Проверяем, существует ли файл
        if (file.exists()) {
            // Создаем Intent для установки APK
            val intent = Intent(Intent.ACTION_VIEW).apply {
                // Устанавливаем URI и MIME-тип для файла
                setDataAndType(
                    FileProvider.getUriForFile(
                        context,
                        "${context.packageName}.provider", // Указываем авторитет FileProvider
                        file
                    ),
                    "application/vnd.android.package-archive" // MIME-тип для APK файлов
                )
                // Добавляем флаг для предоставления разрешения на чтение URI
                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
                // Добавляем флаг для запуска новой задачи
                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            }
            // Запускаем активность для установки APK
            context.startActivity(intent)
        } else {
            // Логируем ошибку, если файл не существует
            Log.e("InstallApk", "APK file does not exist: $apkFilePath")
        }
    }

Проверим? Один из моих пет-проектов с расписанием.

Видео, к сожалению напрямую прикрепить не получилось(

UPD 25.02.25: Вынес код в отдельную библиотеку с документацией, использовать сможет каждый

Vafeen/Direct-Refresher

Заключение

В этой статье был рассмотрен процесс добавления автообновления Android-приложения через GitHub-releases с помощью Retrofit и Hilt.

Более того, в статье предполагается, что читатель умеет использовать Hilt для инъекции зависимостей.

No errors, no warnings, gentlemen and ladies!