feat: in-app APK update + Gitea CI/CD workflow
Some checks failed
Build & Deploy APK / build (push) Failing after 2s
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:
@@ -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">
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
6
app/src/main/res/xml/file_paths.xml
Normal file
6
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path
|
||||
name="apk_updates"
|
||||
path="." />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user