Автообновление Android-приложений через GutHub releases с помощью Hilt и Retrofit в Android

Вдохновившись обновлением 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 говорят сами за себя. Этот поинт предоставляет огромное количество информации (К сожалению, качество оставляет желать лучшего):

30c67a487d08f8fff7d640ab6624da13.png

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

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

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

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

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

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

data class Release(
    val url: String,
    val assets_url: String,
    val upload_url: String,
    val html_url: String,
    val id: Long,
    val author: Author,
    val node_id: String,
    val tag_name: String,
    val target_commitish: String,
    val name: String,
    val draft: Boolean,
    val prerelease: Boolean,
    val created_at: String,
    val published_at: String,
    val assets: List,
    val tarball_url: String,
    val zipball_url: String,
    val body: String
)

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

data class Author(
    val login: String,
    val id: Long,
    val node_id: String,
    val avatar_url: String,
    val gravatar_id: String,
    val url: String,
    val html_url: String,
    val followers_url: String,
    val following_url: String,
    val gists_url: String,
    val starred_url: String,
    val subscriptions_url: String,
    val organizations_url: String,
    val repos_url: String,
    val events_url: String,
    val received_events_url: String,
    val type: String,
    val site_admin: Boolean
)
data class Asset(
    val url: String,
    val id: Long,
    val node_id: String,
    val name: String,
    val label: String?,
    val uploader: Author,
    val content_type: String,
    val state: String,
    val size: Long,
    val download_count: Int,
    val created_at: String,
    val updated_at: String,
    val browser_download_url: String
)

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

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

Для удобной работы через сетевой репозиторий будем инжектить его через 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? = 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
  
}

Метод — 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/blob/main/DownloadService.kt, но немного упростил его и добавил обработку ошибок.

Скачивать и устанавливать файл я буду через синглтон, и причину расскажу позже)

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

object Downloader {
    val sizeFlow = MutableSharedFlow()
    val isUpdateInProcessFlow = MutableSharedFlow()
    
    fun downloadApk(
        networkRepository: NetworkRepository,
        url: String, filePath: String
    ) {
        // Создаем вызов для загрузки файла
        val call = networkRepository.downloadFile(url)

        // Выполняем асинхронный запрос
        call?.enqueue(object : Callback {
            // Обрабатываем успешный ответ
            override fun onResponse(
                call: Call,
                response: Response
            ) {
                // Запускаем корутину для выполнения операции ввода-вывода
                CoroutineScope(Dispatchers.IO).launch(Dispatchers.IO) {
                    try {
                        // Проверяем, успешен ли ответ
                        if (response.isSuccessful) {
                            response.body()?.let { body ->
                                // Создаем файл для записи данных
                                val file = File(filePath)
                                // Получаем поток данных из тела ответа
                                val inputStream = body.byteStream()
                                // Создаем поток для записи данных в файл
                                val outputStream = FileOutputStream(file)
                                // Буфер для чтения данных
                                val buffer = ByteArray(8 * 1024)
                                var bytesRead: Int
                                var totalBytesRead: Long = 0
                                // Получаем длину содержимого
                                val contentLength = body.contentLength()

                                // Используем потоки для чтения и записи данных
                                inputStream.use { input ->
                                    outputStream.use { output ->
                                        while (input.read(buffer).also { bytesRead = it } != -1) {
                                            // запись данных из буфера в выходной поток
                                            output.write(buffer, 0, bytesRead)
                                            totalBytesRead += bytesRead
                                            // Отправляем прогресс загрузки
                                          // Об этом сразу после   
                                          sizeFlow.emit(
                                                Progress(
                                                    totalBytesRead = totalBytesRead,
                                                    contentLength = contentLength,
                                                    done = totalBytesRead == contentLength
                                                )
                                            )
                                        }
                                    }
                                }
                                // Логируем успешную загрузку
                                Log.d("status", "Downloaded")
                            }
                        } else {
                            // Логируем ошибку при неуспешном ответе
                            Log.e("status", "Failed to download file")
                        }
                    } catch (e: Exception) {
                        // Отправляем ошибку в случае проблем
                        CoroutineScope(Dispatchers.IO).launch(Dispatchers.IO) {
                            sizeFlow.emit(Progress(failed = true))
                        }
                    }
                }
            }

            // Обрабатываем ошибку при выполнении запроса
            override fun onFailure(call: Call, t: Throwable) {
                Log.e("status", "Download error: ${t.message}")
            }
        })
    }

}

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

Я использую Downloader в виде синглтона, поскольку так мне не нужно передавать 2 экземпляра SharedFlow по всему дереву абстракций, что позволяет без проблем использовать их в любой части приложения.

В «Отправляем процесс загрузки» я отправляю класс Progress:

data class Progress(
    val totalBytesRead: Long = 0,
    val contentLength: Long = 0,
    val done: Boolean = false,
    val failed: Boolean = false
)

В который я транслирую считанный размер, полный размер и состояния — закончено или нет и есть ли ошибки.

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

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

val versionName = getVersionName(context = context)

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

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

                        Downloader.downloadApk(
                            networkRepository = networkRepository,
                            url = "vafeen/UniversitySchedule/releases/download/${release.tag_name}/${release.assets[0].name}",
                            filePath = "${context.externalCacheDir?.absolutePath}/app-release.apk",
                        )
                        
                        Downloader.isUpdateInProcessFlow.emit(true)
            }
}

Я в своем приложении подписываю на этот поток показ полосы загрузки, а дальше обновление прогресса с последующей установкой:

Downloader.sizeFlow.collect {
            if (!it.failed) {
                progress.value = it // обновление прогресса 
                if (it.contentLength == it.totalBytesRead) { // количество прочитанных данных равно размеру 
                    isUpdateInProcess = false // скрываю полосу загрузку
                  // начинаю установку, которую сейчас разберем    
                    Downloader.installApk(
                        context = context, apkFilePath = "${context.externalCacheDir?.absolutePath}/app-release.apk"
                    )
                }
            } else isUpdateInProcess = false
        }

Установка

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

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


            

и @xml/file_paths.xml



    

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

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

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

fun installApk(context: Context, apkFilePath: String) {
    // Создаем объект 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")
    }
}

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

80ab603df9c78822b4170907f68cdb91.png
ea44237f4d1e8fffc749e0c9bafdc523.png

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

Заключение

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

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

No errors, no warnings, gentlemen and ladies!

© Habrahabr.ru