diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml
new file mode 100644
index 0000000..2409fd3
--- /dev/null
+++ b/.gitea/workflows/build-release.yml
@@ -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)"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index eb6696b..bbc54a3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
+
+
+
+
diff --git a/app/src/main/java/pl/firmatpp/itstransport/MainActivity.kt b/app/src/main/java/pl/firmatpp/itstransport/MainActivity.kt
index 8938e2d..b9a4f41 100644
--- a/app/src/main/java/pl/firmatpp/itstransport/MainActivity.kt
+++ b/app/src/main/java/pl/firmatpp/itstransport/MainActivity.kt
@@ -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() },
+ )
+ }
}
}
}
diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/api/AppUpdateApi.kt b/app/src/main/java/pl/firmatpp/itstransport/data/api/AppUpdateApi.kt
new file mode 100644
index 0000000..ab8925b
--- /dev/null
+++ b/app/src/main/java/pl/firmatpp/itstransport/data/api/AppUpdateApi.kt
@@ -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
+}
diff --git a/app/src/main/java/pl/firmatpp/itstransport/data/repository/AppUpdateRepository.kt b/app/src/main/java/pl/firmatpp/itstransport/data/repository/AppUpdateRepository.kt
new file mode 100644
index 0000000..7defd9a
--- /dev/null
+++ b/app/src/main/java/pl/firmatpp/itstransport/data/repository/AppUpdateRepository.kt
@@ -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 {
+ 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 {
+ 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)
+ }
+ }
+}
diff --git a/app/src/main/java/pl/firmatpp/itstransport/di/NetworkModule.kt b/app/src/main/java/pl/firmatpp/itstransport/di/NetworkModule.kt
index ba0c2ed..ee04a4c 100644
--- a/app/src/main/java/pl/firmatpp/itstransport/di/NetworkModule.kt
+++ b/app/src/main/java/pl/firmatpp/itstransport/di/NetworkModule.kt
@@ -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)
+ }
}
diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/update/AppUpdateViewModel.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/update/AppUpdateViewModel.kt
new file mode 100644
index 0000000..ad619a4
--- /dev/null
+++ b/app/src/main/java/pl/firmatpp/itstransport/ui/update/AppUpdateViewModel.kt
@@ -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.Idle)
+ val state: StateFlow = _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
+ }
+}
diff --git a/app/src/main/java/pl/firmatpp/itstransport/ui/update/UpdateDialog.kt b/app/src/main/java/pl/firmatpp/itstransport/ui/update/UpdateDialog.kt
new file mode 100644
index 0000000..598653e
--- /dev/null
+++ b/app/src/main/java/pl/firmatpp/itstransport/ui/update/UpdateDialog.kt
@@ -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)
+}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..8dbafbd
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/scripts/setup-runner.sh b/scripts/setup-runner.sh
new file mode 100755
index 0000000..dc8c435
--- /dev/null
+++ b/scripts/setup-runner.sh
@@ -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 --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"