Вдохновившись обновлением Telegram без маркета приложений я захотел сделать на одном из своих пет-проектов что-то подобное. Первой мыслью было - найти этот код в исходниках Telegram, но т.к. скорее всего у них обновление скачивается с серверов, я решил не играть в лотерею и не тратить время на раскопки в Java-коде, потому что я хотел сделать так, чтобы можно было скачивать с GitHub-releases.
Итак, начнем
Зависимости
Для работы понадобится Retforit, Hilt. Как подключать и использовать Hilt я рассказывать не буду, об этом множество статей, а вот для Retrofit нужно немного:
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, чтобы не потерять данные в потоках.
Объяснять, как работает этот код долго, поэтому я оставлю комментарии:
@Singleton
class Downloader @Inject constructor(private val networkRepository: NetworkRepository,) {
private val _percentageFlow = MutableSharedFlow<Float>()
val percentageFlow = _percentageFlow.asSharedFlow()
private val _isUpdateInProcessFlow = MutableSharedFlow<Boolean>()
val isUpdateInProcessFlow = _isUpdateInProcessFlow.asSharedFlow()
private fun installApk(context: Context) {
val apkFilePath = context.pathToDownloadRelease()
// Создаем объект 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")
}
}
fun downloadApk(
context: Context,
url: String
) {
CoroutineScope(Dispatchers.IO).launch {
_isUpdateInProcessFlow.emit(true)
}
val apkFilePath = context.pathToDownloadRelease()
// Создаем вызов для загрузки файла
val call = networkRepository.downloadFile(url)
// Выполняем асинхронный запрос
call?.enqueue(object : Callback<ResponseBody> {
// Обрабатываем успешный ответ
override fun onResponse(
call: Call<ResponseBody>,
response: Response<ResponseBody>
) {
CoroutineScope(Dispatchers.IO).launch {
try {
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)
var bytesRead: Int
var totalBytesRead: Long = 0
// Получаем длину содержимого
val contentLength = body.contentLength()
// Используем потоки для чтения и записи данных
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
// запись данных из буфера в выходной поток
outputStream.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
// Отправляем процент загрузки
_percentageFlow.emit(totalBytesRead.toFloat() / contentLength)
if (contentLength == totalBytesRead) {
// отправляем окончание процесса загрузки
_isUpdateInProcessFlow.emit(false)
// установка
installApk(context = context)
}
}
} else {
// Отправляем сигнал о неудаче
_isUpdateInProcessFlow.emit(false)
_percentageFlow.emit(0f)
}
} catch (e: Exception) {
// Обрабатываем исключение и отправляем сигнал о неудаче
_isUpdateInProcessFlow.emit(false)
_percentageFlow.emit(0f)
}
}
}
// Обрабатываем ошибку при выполнении запроса
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
Log.e("status", "Download error: ${t.message}")
}
})
}
}
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: Вынес код в отдельную библиотеку с документацией, использовать сможет каждый
Заключение
В этой статье был рассмотрен процесс добавления автообновления Android-приложения через GitHub-releases с помощью Retrofit и Hilt.
Более того, в статье предполагается, что читатель умеет использовать Hilt для инъекции зависимостей.
No errors, no warnings, gentlemen and ladies!