Check the git for a newer release and install it from the Car screen.
This commit is contained in:
1 parent
b95cbefeab
commit
a164d9c135
6 files changed
+188
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.racingapp.pitlog"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 2
|
||||
versionName = "1.1"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="exports" path="exports/" />
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||
Reference in new issue
Block a user