Check the git for a newer release and install it from the Car screen.

This commit is contained in:
RallyBlock committed 2026-10-22 21:00:41 -07:00
1 parent b95cbefeab
commit a164d9c135
6 files changed
+188 -2

No files matched your search

+2
View File
@@ -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
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.racingapp.pitlog"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "1.1"
}
buildTypes {
+5
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -10,6 +11,10 @@
<action android:name="android.intent.action.SEND" />
<data android:mimeType="text/csv" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent>
</queries>
<application
@@ -0,0 +1,142 @@
package com.racingapp.pitlog.data
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
private const val LATEST_RELEASE =
"https://git.ny.daprogs.com/api/v1/repos/RallyBlock/Pit-Log-App/releases/latest"
private const val MAX_APK_BYTES = 80_000_000
private data class PostedRelease(val versionCode: Long, val apkUrl: String)
fun installedVersionCode(context: Context): Long {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
return if (Build.VERSION.SDK_INT >= 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
}
}
@@ -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),
)
}
}
}
}
+1
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
<cache-path name="updates" path="updates/" />
</paths>