From a164d9c13575483616dc401cdf3f08cfae2d2b0e Mon Sep 17 00:00:00 2001 From: RallyBlock <50393307+RallyBlock@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:00:41 -0700 Subject: [PATCH] Check the git for a newer release and install it from the Car screen. --- README.md | 2 + app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 5 + .../com/racingapp/pitlog/data/AppUpdate.kt | 142 ++++++++++++++++++ .../pitlog/ui/settings/SettingsScreen.kt | 36 +++++ app/src/main/res/xml/file_paths.xml | 1 + 6 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/racingapp/pitlog/data/AppUpdate.kt diff --git a/README.md b/README.md index de9125b..c4b3090 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Each car has its own 8-digit token, shown on the **Car** screen. **Share token** **Remove from this phone** takes the car off that phone only. The other phone and the server keep it. +**Check for updates** on the Car screen looks at the latest release on this git. When a newer one is posted, the app downloads it and Android asks you to install it. + The book icon opens the setup guide. **Car setup** on the home screen is the original setup for the selected car, plus a log of later changes. A race night keeps its own chassis sheet. ## Sync server diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4115341..eaaedf7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.racingapp.pitlog" minSdk = 26 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.1" } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 85d9160..eb31443 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + @@ -10,6 +11,10 @@ + + + + = 28) { + info.longVersionCode + } else { + @Suppress("DEPRECATION") + info.versionCode.toLong() + } +} + +suspend fun checkForAppUpdate(context: Context, onStatus: (String) -> Unit): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) { + val settings = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(settings) + return "Allow Pit Log to install updates, then check again." + } + val release = withContext(Dispatchers.IO) { fetchLatest() } ?: return "Couldn't check for an update." + if (release.versionCode == 0L) return "No update has been posted." + if (release.versionCode <= installedVersionCode(context)) return "This is the latest version." + onStatus("Downloading…") + val file = withContext(Dispatchers.IO) { downloadApk(context, release.apkUrl) } + ?: return "Couldn't download the update." + promptInstall(context, file) + return "Confirm the install when Android asks." +} + +private fun fetchLatest(): PostedRelease? { + val connection = open(LATEST_RELEASE) ?: return null + return try { + val code = connection.responseCode + if (code == 404) return PostedRelease(0, "") + if (code !in 200..299) return null + val body = connection.inputStream.bufferedReader().use { it.readText() } + parseRelease(body) + } catch (_: Exception) { + null + } finally { + connection.disconnect() + } +} + +private fun parseRelease(body: String): PostedRelease? { + val json = JSONObject(body) + val versionCode = json.optString("tag_name").removePrefix("v").toLongOrNull() ?: return null + val assets = json.optJSONArray("assets") ?: return null + for (index in 0 until assets.length()) { + val asset = assets.optJSONObject(index) ?: continue + val name = asset.optString("name") + val url = asset.optString("browser_download_url") + if (name.endsWith(".apk") && url.startsWith("https://")) { + return PostedRelease(versionCode, url) + } + } + return null +} + +private fun downloadApk(context: Context, apkUrl: String): File? { + val connection = open(apkUrl) ?: return null + return try { + if (connection.responseCode !in 200..299) return null + val length = connection.contentLengthLong + if (length > MAX_APK_BYTES) return null + val dir = File(context.cacheDir, "updates").apply { mkdirs() } + val file = File(dir, "pit-log.apk") + connection.inputStream.use { input -> + file.outputStream().use { output -> + val buffer = ByteArray(8192) + var total = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + if (total > MAX_APK_BYTES) { + file.delete() + return null + } + output.write(buffer, 0, read) + } + } + } + val zip = file.inputStream().use { stream -> stream.read() == 0x50 && stream.read() == 0x4B } + if (!zip) { + file.delete() + return null + } + file + } catch (_: Exception) { + null + } finally { + connection.disconnect() + } +} + +private fun promptInstall(context: Context, file: File) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val install = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + clipData = ClipData.newRawUri("Pit Log", uri) + } + context.startActivity(install) +} + +private fun open(address: String): HttpURLConnection? { + return try { + (URL(address).openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = true + connectTimeout = 20_000 + readTimeout = 120_000 + setRequestProperty("User-Agent", "PitLog") + setRequestProperty("Accept", "application/json, application/vnd.android.package-archive") + } + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/com/racingapp/pitlog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/racingapp/pitlog/ui/settings/SettingsScreen.kt index 4c90035..035b668 100644 --- a/app/src/main/java/com/racingapp/pitlog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/racingapp/pitlog/ui/settings/SettingsScreen.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import android.content.Context import com.racingapp.pitlog.data.Car +import com.racingapp.pitlog.data.checkForAppUpdate import com.racingapp.pitlog.data.DEFAULT_CAR import com.racingapp.pitlog.data.DEFAULT_CLASS import com.racingapp.pitlog.data.DEFAULT_DISCIPLINE @@ -62,6 +64,9 @@ class SettingsViewModel(private val repository: Repository) : ViewModel() { private set var syncing by mutableStateOf(false) private set + var updating by mutableStateOf(false) + private set + var updateStatus by mutableStateOf("") init { serverAddress = repository.syncAddress() @@ -148,6 +153,20 @@ class SettingsViewModel(private val repository: Repository) : ViewModel() { } } + fun checkForUpdate(context: Context) { + if (updating) return + updating = true + updateStatus = "Checking…" + viewModelScope.launch { + updateStatus = try { + checkForAppUpdate(context) { updateStatus = it } + } catch (_: Exception) { + "Couldn't check for an update." + } + updating = false + } + } + fun changeAutomatic(enabled: Boolean) { automatic = enabled repository.saveAddress(serverAddress) @@ -325,6 +344,23 @@ fun SettingsScreen(onBack: () -> Unit) { modifier = Modifier.padding(top = 8.dp), ) } + SectionLabel("App") + OutlinedButton( + onClick = { viewModel.checkForUpdate(context) }, + enabled = !viewModel.updating, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) { + Text(if (viewModel.updating) "Checking…" else "Check for updates") + } + if (viewModel.updateStatus.isNotBlank()) { + Text( + viewModel.updateStatus, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } } } } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 282e644..5be802d 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ +