feat: in-app APK update + Gitea CI/CD workflow
Some checks failed
Build & Deploy APK / build (push) Failing after 2s

- Check /api/app/android/version on launch, prompt user to update
- Download APK and trigger install via FileProvider
- Add Gitea Actions workflow to build release APK and deploy on push
- Add setup script for Act Runner (Java 17 + Android SDK)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-03-25 16:22:45 +01:00
parent 13ed7d911e
commit cdd22a2d4b
10 changed files with 567 additions and 0 deletions

View File

@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".ITSTransportApp"
@@ -21,6 +22,16 @@
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".data.fcm.FCMService"
android:exported="false">

View File

@@ -4,10 +4,16 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import pl.firmatpp.itstransport.data.auth.TokenManager
import pl.firmatpp.itstransport.ui.navigation.AppNavigation
import pl.firmatpp.itstransport.ui.theme.ITSTransportTheme
import pl.firmatpp.itstransport.ui.update.AppUpdateViewModel
import pl.firmatpp.itstransport.ui.update.UpdateDialog
import pl.firmatpp.itstransport.ui.update.UpdateState
import javax.inject.Inject
@AndroidEntryPoint
@@ -16,12 +22,27 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var tokenManager: TokenManager
private val updateViewModel: AppUpdateViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
updateViewModel.checkForUpdate()
setContent {
ITSTransportTheme {
val updateState by updateViewModel.state.collectAsStateWithLifecycle()
AppNavigation(tokenManager = tokenManager)
if (updateState != UpdateState.Idle) {
UpdateDialog(
state = updateState,
onDownload = { url -> updateViewModel.startDownload(url) },
onDismiss = { updateViewModel.dismiss() },
)
}
}
}
}

View File

@@ -0,0 +1,23 @@
package pl.firmatpp.itstransport.data.api
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Streaming
import retrofit2.http.Url
data class AppVersionResponse(
val versionName: String,
val versionCode: Int,
val downloadUrl: String,
val releaseNotes: String?,
)
interface AppUpdateApi {
@GET("app/android/version")
suspend fun getLatestVersion(): AppVersionResponse
@Streaming
@GET
suspend fun downloadApk(@Url url: String): Response<ResponseBody>
}

View File

@@ -0,0 +1,62 @@
package pl.firmatpp.itstransport.data.repository
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import pl.firmatpp.itstransport.BuildConfig
import pl.firmatpp.itstransport.data.api.AppUpdateApi
import pl.firmatpp.itstransport.data.api.AppVersionResponse
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppUpdateRepository @Inject constructor(
private val appUpdateApi: AppUpdateApi,
@ApplicationContext private val context: Context,
) {
companion object {
private const val TAG = "AppUpdateRepo"
private const val APK_FILENAME = "itstransport-update.apk"
}
suspend fun checkForUpdate(): Result<AppVersionResponse?> {
return try {
val latest = appUpdateApi.getLatestVersion()
Log.d(TAG, "Remote version: ${latest.versionName} (${latest.versionCode}), local: ${BuildConfig.VERSION_CODE}")
if (latest.versionCode > BuildConfig.VERSION_CODE) {
Result.success(latest)
} else {
Result.success(null)
}
} catch (e: Exception) {
Log.w(TAG, "Version check failed: ${e.javaClass.simpleName}${e.message}")
Result.failure(e)
}
}
suspend fun downloadApk(downloadUrl: String): Result<File> {
return try {
Log.d(TAG, "Downloading APK from: $downloadUrl")
val response = appUpdateApi.downloadApk(downloadUrl)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("Download failed: HTTP ${response.code()}"))
}
val apkFile = File(context.getExternalFilesDir(null), APK_FILENAME)
response.body()!!.byteStream().use { input ->
apkFile.outputStream().use { output ->
input.copyTo(output)
}
}
Log.d(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
Result.success(apkFile)
} catch (e: Exception) {
Log.w(TAG, "APK download failed: ${e.javaClass.simpleName}${e.message}")
Result.failure(e)
}
}
}

View File

@@ -7,6 +7,7 @@ import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import pl.firmatpp.itstransport.BuildConfig
import pl.firmatpp.itstransport.data.api.AppUpdateApi
import pl.firmatpp.itstransport.data.api.AuthApi
import pl.firmatpp.itstransport.data.api.AuthInterceptor
import pl.firmatpp.itstransport.data.api.DeviceTokenApi
@@ -90,4 +91,10 @@ object NetworkModule {
fun provideDeviceTokenApi(retrofit: Retrofit): DeviceTokenApi {
return retrofit.create(DeviceTokenApi::class.java)
}
@Provides
@Singleton
fun provideAppUpdateApi(retrofit: Retrofit): AppUpdateApi {
return retrofit.create(AppUpdateApi::class.java)
}
}

View File

@@ -0,0 +1,83 @@
package pl.firmatpp.itstransport.ui.update
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import pl.firmatpp.itstransport.data.repository.AppUpdateRepository
import java.io.File
import javax.inject.Inject
sealed class UpdateState {
data object Idle : UpdateState()
data class Available(
val versionName: String,
val releaseNotes: String?,
val downloadUrl: String,
) : UpdateState()
data class Downloading(val progress: String) : UpdateState()
data class ReadyToInstall(val apkFile: File) : UpdateState()
data class Error(val message: String) : UpdateState()
}
@HiltViewModel
class AppUpdateViewModel @Inject constructor(
private val repository: AppUpdateRepository,
) : ViewModel() {
companion object {
private const val TAG = "AppUpdateVM"
}
private val _state = MutableStateFlow<UpdateState>(UpdateState.Idle)
val state: StateFlow<UpdateState> = _state.asStateFlow()
fun checkForUpdate() {
viewModelScope.launch {
val result = repository.checkForUpdate()
result.fold(
onSuccess = { version ->
if (version != null) {
Log.d(TAG, "Update available: ${version.versionName}")
_state.value = UpdateState.Available(
versionName = version.versionName,
releaseNotes = version.releaseNotes,
downloadUrl = version.downloadUrl,
)
} else {
Log.d(TAG, "App is up to date")
}
},
onFailure = {
// Silently ignore — update check is non-critical
Log.d(TAG, "Update check failed, ignoring")
},
)
}
}
fun startDownload(downloadUrl: String) {
_state.value = UpdateState.Downloading("Pobieranie...")
viewModelScope.launch {
val result = repository.downloadApk(downloadUrl)
result.fold(
onSuccess = { apkFile ->
Log.d(TAG, "APK ready to install: ${apkFile.absolutePath}")
_state.value = UpdateState.ReadyToInstall(apkFile)
},
onFailure = { error ->
Log.w(TAG, "Download failed: ${error.message}")
_state.value = UpdateState.Error("Nie udało się pobrać aktualizacji.")
},
)
}
}
fun dismiss() {
_state.value = UpdateState.Idle
}
}

View File

@@ -0,0 +1,152 @@
package pl.firmatpp.itstransport.ui.update
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import java.io.File
@Composable
fun UpdateDialog(
state: UpdateState,
onDownload: (downloadUrl: String) -> Unit,
onDismiss: () -> Unit,
) {
when (state) {
is UpdateState.Available -> {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Dostępna aktualizacja")
},
text = {
Column {
Text(
text = "Nowa wersja: ${state.versionName}",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
if (!state.releaseNotes.isNullOrBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.releaseNotes,
style = MaterialTheme.typography.bodyMedium,
)
}
}
},
confirmButton = {
Button(onClick = { onDownload(state.downloadUrl) }) {
Text("Pobierz i zainstaluj")
}
},
dismissButton = {
OutlinedButton(onClick = onDismiss) {
Text("Później")
}
},
)
}
is UpdateState.Downloading -> {
AlertDialog(
onDismissRequest = {},
title = {
Text("Pobieranie aktualizacji")
},
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(),
) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = state.progress,
style = MaterialTheme.typography.bodyMedium,
)
}
},
confirmButton = {},
)
}
is UpdateState.ReadyToInstall -> {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Aktualizacja gotowa")
},
text = {
Text("Aktualizacja została pobrana. Kliknij zainstaluj, aby zaktualizować aplikację.")
},
confirmButton = {
Button(onClick = {
installApk(context, state.apkFile)
}) {
Text("Zainstaluj")
}
},
dismissButton = {
OutlinedButton(onClick = onDismiss) {
Text("Później")
}
},
)
}
is UpdateState.Error -> {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Błąd aktualizacji")
},
text = {
Text(state.message)
},
confirmButton = {
Button(onClick = onDismiss) {
Text("OK")
}
},
)
}
UpdateState.Idle -> {
// No dialog shown
}
}
}
private fun installApk(context: Context, apkFile: File) {
val uri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
context.startActivity(intent)
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="apk_updates"
path="." />
</paths>