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"