Auto-update apps via GutHub releases with Hilt and Retrofit on Android

Inspired by the Telegram update without the app market, I wanted to do something similar on one of my pet projects. My first thought was to find this code in the Telegram source code, but since they most likely download the update from servers, I decided not to play the lottery and not waste time digging through the Java code, because I wanted to make it so that it could be downloaded from GitHub releases.

So, let's begin

Dependencies

For work you will need Retforit, Hilt. I will not tell you how to connect and use Hilt, there are many articles about this, but for Retrofit you need a little:

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

In order to make an automatic update of the application, you need to know that there is an update and know what to download.

There is a lot of information about GitHub-Api on the Internet, so I will tell you briefly. The point where you can get information about the latest release looks like this: https://api.github.com/repos/{user}/{repository}/releases/latest, where user and repository speak for themselves. This point provides a huge amount of information (Unfortunately, the quality leaves much to be desired):

All we need from this is – tag_name and the name of the apk file is – name – in assets. It is quite easy to get this in an Android application via Retrofit.

Retrofit service for getting release data

First, let's add in AndroidManifest permission to use the internet:

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

The service looks like all standard Retrofit services, we only need one GET method:

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

GsonConverterFactory here is needed to automatically convert the answers into the required data. We will convert to class Releasewhich, based on the data at the point, will look like this:

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<Asset>,
    val tarball_url: String,
    val zipball_url: String,
    val body: String
)

It also uses classes inside it. Author And 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
)

You will have to ignore the warnings since the field names here are the names of the keys in Json or use the `SerializedName` annotation

Network repository

For convenient work via the network repository, we will inject it via Hilt. Let's start with the module:

@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)
}

The network repository itself is a regular abstract class that will look like this:

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

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

getLatestRelease turns into a block try {} catch {}since when receiving data from the network, a huge number of errors may occur, such as: long waits, absence or unexpected disconnection of the Internet, and much more.

Checking the application version

At this point, when we have the latest release version, we need to find out if the application needs an update. Tags are usually named using the pattern “v” + `version number`. In the example, the latest release is version 1.3, because tag_name == v1.3so for the application to be up-to-date, it needs to be versionNamewhich is specified in Gradle, matches the version name in the last tag.

You can find out the application version programmatically as follows:

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

Earlier versionName returned String, but in recent versions of Android Studio forces you to specify a nullable type, but if the version is specified in each release, there is nothing to worry about, it will return here.

A full check with data retrieval will look like this:

val versionName = getVersionName(context = context)

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

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

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

Updating the application

It is assumed that the current version of the application is lower than the latest release version, and the latest version is added to GitHub releases.

Updating the app will involve downloading the APK file and prompting the user to install it.

Downloading APK file

To download the file, you need another Retrofit service

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

Method – GET

Streaming – this annotation tells Retrofit that the response from the server should be processed as data arrives, rather than loaded entirely into memory. (It will be important to handle progress later, since if you simply subscribe to this stream, the interface will freeze due to the number of operations)

Class Сall represents an HTTP request that can be executed asynchronously or synchronously. It provides methods for executing the request and handling the response.

ResponseBody needed to obtain the response body.

We will also add the implementation of this interface to RetrofitDIModule and repository constructor

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

Downloading and installing the file

Download

To work with the network, you need permission to use the Internet:

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

I found the file download code on the Internet at the link https://github.com/mt-ks/kotlin-file-download but simplified it a bit and added error handling.

I will download and install the file via singleton, and I will tell you the reason later)

It would take a long time to explain how this code works, so I'll leave comments:

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

        // Выполняем асинхронный запрос
        call?.enqueue(object : Callback<ResponseBody> {
            // Обрабатываем успешный ответ
            override fun onResponse(
                call: Call<ResponseBody>,
                response: Response<ResponseBody>
            ) {
                // Запускаем корутину для выполнения операции ввода-вывода
                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<ResponseBody>, t: Throwable) {
                Log.e("status", "Download error: ${t.message}")
            }
        })
    }

}

sizeFlow And isUpdateInProcessFlow needed to subscribe to these Flow on the screens and update the interface accordingly. At the end of the article I will show you on video how I did it. It uses SharedFlowbecause from the usual Flow it differs in that SharedFlow stores the entire history of changes and keeps records even in the absence of subscribers, and when they appear, it simply broadcasts all the data, and the usual Flow records only when subscribed to.

I use Downloader as a singleton because that way I don't have to pass 2 instances SharedFlow across the entire abstraction tree, allowing them to be used seamlessly in any part of the application.

In “Sending the loading process” I send the class Progress:

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

In which I broadcast the read size, the full size and the states – finished or not and whether there are errors.

Let's go back to the moment of receiving the release data, where you need to insert the update:

Here we start downloading and notify isUpdateInProcessFlow about the start of the download.

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)
            }
}

In my application, I subscribe to this stream to show the loading bar, and then update the progress with subsequent installation:

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
        }

Installation

To create requests to install packages, the application needs permission:

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

In the manifest inside you should specify the following code to configure 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>

And @xml/file_paths.xml

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

FileProvider allows you to securely transfer files between applications by providing temporary URIs that can be used to access files.

In this case, the installer is launched with the installation file by URI.

Setting to add to 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")
    }
}

Let's check? One of my pet projects with a schedule.

Unfortunately, I couldn't attach the video directly(

Conclusion

This article covered the process of adding auto-updates to an Android app via GitHub releases using Retrofit and Hilt.

Moreover, the article assumes that the reader knows how to use Hilt for dependency injection.

No errors, no warnings, gentlemen and ladies!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *