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:
50
.gitea/workflows/build-release.yml
Normal file
50
.gitea/workflows/build-release.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Build & Deploy APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate environment
|
||||||
|
run: |
|
||||||
|
echo "Java: $(java --version 2>&1 | head -1)"
|
||||||
|
echo "ANDROID_HOME: $ANDROID_HOME"
|
||||||
|
test -d "$ANDROID_HOME" || { echo "ERROR: ANDROID_HOME not set"; exit 1; }
|
||||||
|
|
||||||
|
- name: Build Release APK
|
||||||
|
run: |
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew assembleRelease --no-daemon
|
||||||
|
|
||||||
|
- name: Deploy APK & update version
|
||||||
|
run: |
|
||||||
|
STORAGE="/home/user/itstransport/backend/storage/app/android"
|
||||||
|
|
||||||
|
VERSION_NAME=$(grep 'versionName' app/build.gradle.kts | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
VERSION_CODE=$(grep 'versionCode' app/build.gradle.kts | head -1 | sed 's/[^0-9]*//g')
|
||||||
|
|
||||||
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
|
test -n "$APK" || { echo "ERROR: No APK found"; exit 1; }
|
||||||
|
|
||||||
|
cp "$APK" "$STORAGE/app-release.apk"
|
||||||
|
|
||||||
|
COMMIT_MSG=$(git log -1 --pretty=%s | sed 's/"/\\"/g')
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
data = {
|
||||||
|
'versionName': '$VERSION_NAME',
|
||||||
|
'versionCode': int('$VERSION_CODE'),
|
||||||
|
'releaseNotes': \"$COMMIT_MSG\"
|
||||||
|
}
|
||||||
|
with open('$STORAGE/version.json', 'w') as f:
|
||||||
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||||
|
f.write('\n')
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Deployed v${VERSION_NAME} (code ${VERSION_CODE}) — $(du -h "$STORAGE/app-release.apk" | cut -f1)"
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ITSTransportApp"
|
android:name=".ITSTransportApp"
|
||||||
@@ -21,6 +22,16 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<service
|
||||||
android:name=".data.fcm.FCMService"
|
android:name=".data.fcm.FCMService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import pl.firmatpp.itstransport.data.auth.TokenManager
|
import pl.firmatpp.itstransport.data.auth.TokenManager
|
||||||
import pl.firmatpp.itstransport.ui.navigation.AppNavigation
|
import pl.firmatpp.itstransport.ui.navigation.AppNavigation
|
||||||
import pl.firmatpp.itstransport.ui.theme.ITSTransportTheme
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -16,12 +22,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var tokenManager: TokenManager
|
lateinit var tokenManager: TokenManager
|
||||||
|
|
||||||
|
private val updateViewModel: AppUpdateViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
updateViewModel.checkForUpdate()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ITSTransportTheme {
|
ITSTransportTheme {
|
||||||
|
val updateState by updateViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
AppNavigation(tokenManager = tokenManager)
|
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.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import pl.firmatpp.itstransport.BuildConfig
|
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.AuthApi
|
||||||
import pl.firmatpp.itstransport.data.api.AuthInterceptor
|
import pl.firmatpp.itstransport.data.api.AuthInterceptor
|
||||||
import pl.firmatpp.itstransport.data.api.DeviceTokenApi
|
import pl.firmatpp.itstransport.data.api.DeviceTokenApi
|
||||||
@@ -90,4 +91,10 @@ object NetworkModule {
|
|||||||
fun provideDeviceTokenApi(retrofit: Retrofit): DeviceTokenApi {
|
fun provideDeviceTokenApi(retrofit: Retrofit): DeviceTokenApi {
|
||||||
return retrofit.create(DeviceTokenApi::class.java)
|
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>
|
||||||
152
scripts/setup-runner.sh
Executable file
152
scripts/setup-runner.sh
Executable file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Setup Gitea Act Runner for Android APK builds
|
||||||
|
# Run this ONCE on the build machine (this WSL instance)
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
set -e
|
||||||
|
|
||||||
|
GITEA_URL="http://192.168.5.10:3000"
|
||||||
|
GITEA_TOKEN="4316139b56813d461f5a5a4fa6fa8eb94b5e37e6"
|
||||||
|
RUNNER_DIR="$HOME/act_runner"
|
||||||
|
ANDROID_HOME_DIR="$HOME/android-sdk"
|
||||||
|
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Step 1: Install Java 17"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
if java --version 2>&1 | grep -q "17"; then
|
||||||
|
echo "Java 17 already installed"
|
||||||
|
else
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y openjdk-17-jdk-headless
|
||||||
|
fi
|
||||||
|
echo "Java: $(java --version 2>&1 | head -1)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Step 2: Install Android SDK"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
if [ -d "$ANDROID_HOME_DIR/platforms" ]; then
|
||||||
|
echo "Android SDK already installed at $ANDROID_HOME_DIR"
|
||||||
|
else
|
||||||
|
mkdir -p "$ANDROID_HOME_DIR"
|
||||||
|
cd /tmp
|
||||||
|
|
||||||
|
# Download Android command-line tools
|
||||||
|
CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
||||||
|
echo "Downloading Android command-line tools..."
|
||||||
|
wget -q "$CMDLINE_TOOLS_URL" -O cmdline-tools.zip
|
||||||
|
unzip -qo cmdline-tools.zip -d "$ANDROID_HOME_DIR"
|
||||||
|
mkdir -p "$ANDROID_HOME_DIR/cmdline-tools/latest"
|
||||||
|
mv "$ANDROID_HOME_DIR/cmdline-tools/bin" "$ANDROID_HOME_DIR/cmdline-tools/latest/"
|
||||||
|
mv "$ANDROID_HOME_DIR/cmdline-tools/lib" "$ANDROID_HOME_DIR/cmdline-tools/latest/"
|
||||||
|
rm -f cmdline-tools.zip
|
||||||
|
|
||||||
|
# Accept licenses and install required SDK components
|
||||||
|
export ANDROID_HOME="$ANDROID_HOME_DIR"
|
||||||
|
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null 2>&1 || true
|
||||||
|
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
|
"platforms;android-35" \
|
||||||
|
"build-tools;35.0.0" \
|
||||||
|
"platform-tools"
|
||||||
|
|
||||||
|
echo "Android SDK installed at $ANDROID_HOME_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Step 3: Install Act Runner"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
mkdir -p "$RUNNER_DIR"
|
||||||
|
cd "$RUNNER_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$RUNNER_DIR/act_runner" ]; then
|
||||||
|
RUNNER_URL="https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64"
|
||||||
|
echo "Downloading act_runner..."
|
||||||
|
wget -q "$RUNNER_URL" -O act_runner
|
||||||
|
chmod +x act_runner
|
||||||
|
echo "Act runner downloaded"
|
||||||
|
else
|
||||||
|
echo "Act runner already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Step 4: Register runner with Gitea"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
if [ -f "$RUNNER_DIR/.runner" ]; then
|
||||||
|
echo "Runner already registered"
|
||||||
|
else
|
||||||
|
# Get registration token via API
|
||||||
|
REG_TOKEN=$(curl -s -X GET \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$GITEA_URL/api/v1/repos/admin/itstransport-android/actions/runners/registration-token" \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))")
|
||||||
|
|
||||||
|
if [ -z "$REG_TOKEN" ]; then
|
||||||
|
echo "ERROR: Could not get registration token."
|
||||||
|
echo "Go to Gitea → itstransport-android → Settings → Actions → Runners"
|
||||||
|
echo "Create a runner token and run:"
|
||||||
|
echo " cd $RUNNER_DIR && ./act_runner register --instance $GITEA_URL --token <TOKEN> --labels ubuntu-latest:host --name its-android-builder"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Registering runner..."
|
||||||
|
./act_runner register \
|
||||||
|
--instance "$GITEA_URL" \
|
||||||
|
--token "$REG_TOKEN" \
|
||||||
|
--labels "ubuntu-latest:host" \
|
||||||
|
--name "its-android-builder" \
|
||||||
|
--no-interactive
|
||||||
|
echo "Runner registered!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " Step 5: Create systemd service"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/act-runner.service"
|
||||||
|
if [ -f "$SERVICE_FILE" ]; then
|
||||||
|
echo "Service already exists"
|
||||||
|
else
|
||||||
|
sudo tee "$SERVICE_FILE" > /dev/null << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Gitea Act Runner
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$USER
|
||||||
|
WorkingDirectory=$RUNNER_DIR
|
||||||
|
Environment="ANDROID_HOME=$ANDROID_HOME_DIR"
|
||||||
|
Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64"
|
||||||
|
Environment="PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$ANDROID_HOME_DIR/cmdline-tools/latest/bin:$ANDROID_HOME_DIR/platform-tools:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
ExecStart=$RUNNER_DIR/act_runner daemon
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable act-runner
|
||||||
|
sudo systemctl start act-runner
|
||||||
|
echo "Service created and started!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " DONE!"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo "Runner is running. To check status:"
|
||||||
|
echo " sudo systemctl status act-runner"
|
||||||
|
echo ""
|
||||||
|
echo "To test, push a commit to main:"
|
||||||
|
echo " cd ~/itstransport/android"
|
||||||
|
echo " git push origin main"
|
||||||
|
echo ""
|
||||||
|
echo "The workflow will:"
|
||||||
|
echo " 1. Build the release APK"
|
||||||
|
echo " 2. Copy it to backend storage"
|
||||||
|
echo " 3. Update version.json endpoint"
|
||||||
Reference in New Issue
Block a user