TASK-073: the app fetches and installs its own update, without a file
"업데이트" streams the published APK into a system install session instead of opening the browser, so nothing is saved to the phone. Progress, cancel and retry are shown. Android 12+ is asked to skip its confirmation, which it does once this app is the installer of the copy being replaced. Adds REQUEST_INSTALL_PACKAGES and UPDATE_PACKAGES_WITHOUT_USER_ACTION. Not run on a device. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
60016d1537
commit
7d41e89c99
@ -3,6 +3,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- TASK-036: favorited-repository push checker's local notification. -->
|
<!-- TASK-036: favorited-repository push checker's local notification. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- TASK-073: the app installs its own updates. The second lets Android 12+ skip its
|
||||||
|
confirmation once this app is the one that installed the copy being replaced. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@ -30,5 +34,8 @@
|
|||||||
android:path="/ubisampaju/oauth/callback" />
|
android:path="/ubisampaju/oauth/callback" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<receiver
|
||||||
|
android:name=".core.update.AppInstallReceiver"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import android.graphics.Color
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.SystemBarStyle
|
import androidx.activity.SystemBarStyle
|
||||||
@ -25,9 +26,13 @@ import androidx.compose.animation.slideInHorizontally
|
|||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -44,12 +49,13 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||||
import com.ubisam.ubgitea.core.settings.ThemeMode
|
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||||
import com.ubisam.ubgitea.core.update.AppUpdateLocations
|
|
||||||
import com.ubisam.ubgitea.core.update.AppUpdateViewModel
|
import com.ubisam.ubgitea.core.update.AppUpdateViewModel
|
||||||
|
import com.ubisam.ubgitea.core.update.InstallStep
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
@ -123,8 +129,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
|
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
|
||||||
CodeWorkbenchViewModel.Factory(application)
|
CodeWorkbenchViewModel.Factory(application)
|
||||||
}
|
}
|
||||||
|
private val allowInstalls =
|
||||||
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
if (packageManager.canRequestPackageInstalls()) updateViewModel.installUpdate()
|
||||||
|
}
|
||||||
private val updateViewModel: AppUpdateViewModel by viewModels {
|
private val updateViewModel: AppUpdateViewModel by viewModels {
|
||||||
AppUpdateViewModel.Factory()
|
AppUpdateViewModel.Factory(application)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
|
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
|
||||||
@ -523,7 +533,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
update = updateState,
|
update = updateState,
|
||||||
onCheckUpdate = updateViewModel::checkNow,
|
onCheckUpdate = updateViewModel::checkNow,
|
||||||
onOpenDownloadPage = ::openDownloadPage,
|
onInstallUpdate = ::startUpdate,
|
||||||
)
|
)
|
||||||
Route.Notifications -> NotificationsScreen(
|
Route.Notifications -> NotificationsScreen(
|
||||||
state = notificationsState,
|
state = notificationsState,
|
||||||
@ -629,16 +639,52 @@ class MainActivity : ComponentActivity() {
|
|||||||
Text(if (release.notes != null) body + "\n\n" + release.notes else body)
|
Text(if (release.notes != null) body + "\n\n" + release.notes else body)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = ::startUpdate) { Text(stringResource(R.string.update_dialog_download)) }
|
||||||
updateViewModel.dismissPrompt()
|
|
||||||
openDownloadPage()
|
|
||||||
}) { Text(stringResource(R.string.update_dialog_download)) }
|
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = updateViewModel::dismissPrompt) { Text(stringResource(R.string.update_dialog_later)) }
|
TextButton(onClick = updateViewModel::dismissPrompt) { Text(stringResource(R.string.update_dialog_later)) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
when (updateState.install) {
|
||||||
|
InstallStep.IDLE -> Unit
|
||||||
|
InstallStep.DOWNLOADING, InstallStep.INSTALLING -> {
|
||||||
|
val downloading = updateState.install == InstallStep.DOWNLOADING
|
||||||
|
AlertDialog(
|
||||||
|
// Only its button closes it: a stray tap should not drop a download.
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text(stringResource(R.string.update_progress_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
if (downloading) {
|
||||||
|
val fraction = updateState.downloaded
|
||||||
|
Text(stringResource(R.string.update_progress_downloading, (fraction * 100).toInt()))
|
||||||
|
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth())
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.update_progress_installing))
|
||||||
|
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = updateViewModel::cancelInstall) {
|
||||||
|
Text(stringResource(if (downloading) R.string.update_progress_cancel else R.string.update_install_close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InstallStep.FAILED -> AlertDialog(
|
||||||
|
onDismissRequest = updateViewModel::cancelInstall,
|
||||||
|
title = { Text(stringResource(R.string.update_install_failed_title)) },
|
||||||
|
text = { Text(stringResource(R.string.update_install_failed_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = ::startUpdate) { Text(stringResource(R.string.update_install_retry)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = updateViewModel::cancelInstall) { Text(stringResource(R.string.update_install_close)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
SplashOverlay()
|
SplashOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -669,14 +715,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TASK-070: the address is the APK itself, so the browser it opens in downloads it.
|
// TASK-073: the app fetches and installs the new build itself. Android asks once, in its own
|
||||||
private fun openDownloadPage() {
|
// settings, whether this app may install apps; the update starts when the person comes back.
|
||||||
|
private fun startUpdate() {
|
||||||
|
if (packageManager.canRequestPackageInstalls()) {
|
||||||
|
updateViewModel.installUpdate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateViewModel.dismissPrompt()
|
||||||
|
Toast.makeText(this, R.string.update_allow_installs, Toast.LENGTH_LONG).show()
|
||||||
try {
|
try {
|
||||||
CustomTabsIntent.Builder()
|
allowInstalls.launch(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")))
|
||||||
.build()
|
|
||||||
.launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD))
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
} catch (_: ActivityNotFoundException) {
|
||||||
Toast.makeText(this, AppUpdateLocations.DOWNLOAD, Toast.LENGTH_LONG).show()
|
// No such settings screen on this device: nothing more the app can do.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
155
app/src/main/java/com/ubisam/ubgitea/core/update/AppInstaller.kt
Normal file
155
app/src/main/java/com/ubisam/ubgitea/core/update/AppInstaller.kt
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
package com.ubisam.ubgitea.core.update
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.os.Build
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
/** Why an update that was started did not go in. */
|
||||||
|
enum class InstallFailure { CANCELLED, FAILED }
|
||||||
|
|
||||||
|
fun interface AppUpdateInstaller {
|
||||||
|
/**
|
||||||
|
* Fetches the published build and hands it to the system to install over this one. A build
|
||||||
|
* that goes in ends the process, so this returns only when it did not: the reason is the
|
||||||
|
* result. [onDownloaded] is the fraction fetched so far; [onHandedOver] is called once the
|
||||||
|
* system has all of it.
|
||||||
|
*/
|
||||||
|
suspend fun install(onDownloaded: (Float) -> Unit, onHandedOver: () -> Unit): InstallFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TASK-073: the update without a file. The APK is read from the network straight into one of the
|
||||||
|
* system's own install sessions, so nothing lands in the downloads folder or in the app's storage
|
||||||
|
* and there is nothing to clean up. The system checks that the build is signed like the one
|
||||||
|
* installed before it replaces it.
|
||||||
|
*
|
||||||
|
* The first update asks the person to confirm. From Android 12 the system may skip that once this
|
||||||
|
* app is the one that installed the copy being replaced; it is asked to, and decides for itself.
|
||||||
|
*/
|
||||||
|
class SessionAppInstaller(
|
||||||
|
context: Context,
|
||||||
|
private val url: String = AppUpdateLocations.DOWNLOAD,
|
||||||
|
private val client: OkHttpClient = updateClient,
|
||||||
|
) : AppUpdateInstaller {
|
||||||
|
private val context = context.applicationContext
|
||||||
|
|
||||||
|
override suspend fun install(onDownloaded: (Float) -> Unit, onHandedOver: () -> Unit): InstallFailure =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
var sessionId = -1
|
||||||
|
var committed = false
|
||||||
|
try {
|
||||||
|
AppInstallResults.clear()
|
||||||
|
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext InstallFailure.FAILED
|
||||||
|
val body = response.body
|
||||||
|
val length = body.contentLength()
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
params.setAppPackageName(context.packageName)
|
||||||
|
if (length > 0) params.setSize(length)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
sessionId = installer.createSession(params)
|
||||||
|
installer.openSession(sessionId).use { session ->
|
||||||
|
session.openWrite("UBGitea.apk", 0, if (length > 0) length else -1).use { out ->
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
var written = 0L
|
||||||
|
var shownPercent = -1
|
||||||
|
while (true) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
out.write(buffer, 0, read)
|
||||||
|
written += read
|
||||||
|
val percent = if (length > 0) (written * 100 / length).toInt() else 0
|
||||||
|
if (percent != shownPercent) {
|
||||||
|
shownPercent = percent
|
||||||
|
onDownloaded(percent / 100f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.fsync(out)
|
||||||
|
}
|
||||||
|
onHandedOver()
|
||||||
|
val result = Intent(context, AppInstallReceiver::class.java)
|
||||||
|
// The system adds the outcome to the intent, so it has to be mutable.
|
||||||
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||||
|
session.commit(PendingIntent.getBroadcast(context, sessionId, result, flags).intentSender)
|
||||||
|
committed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppInstallResults.await()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
InstallFailure.FAILED
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
InstallFailure.FAILED
|
||||||
|
} finally {
|
||||||
|
if (sessionId != -1 && !committed) {
|
||||||
|
try {
|
||||||
|
installer.abandonSession(sessionId)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the system's answer to an install session reaches the installer that is waiting for it. */
|
||||||
|
internal object AppInstallResults {
|
||||||
|
private val failures = Channel<InstallFailure>(Channel.CONFLATED)
|
||||||
|
|
||||||
|
fun report(failure: InstallFailure) {
|
||||||
|
failures.trySend(failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
while (failures.tryReceive().isSuccess) Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun await(): InstallFailure = failures.receive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receives what the system says about the install session. Not exported: only the system's own sender reaches it. */
|
||||||
|
class AppInstallReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||||
|
// The system wants the person to agree: show its own confirmation screen.
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||||
|
}
|
||||||
|
if (confirm == null) {
|
||||||
|
AppInstallResults.report(InstallFailure.FAILED)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
context.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
AppInstallResults.report(InstallFailure.FAILED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> Unit
|
||||||
|
PackageInstaller.STATUS_FAILURE_ABORTED -> AppInstallResults.report(InstallFailure.CANCELLED)
|
||||||
|
else -> AppInstallResults.report(InstallFailure.FAILED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,7 +62,7 @@ class HttpAppReleaseSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val updateClient: OkHttpClient by lazy {
|
internal val updateClient: OkHttpClient by lazy {
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
.readTimeout(10, TimeUnit.SECONDS)
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.ubisam.ubgitea.core.update
|
package com.ubisam.ubgitea.core.update
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@ -13,6 +14,9 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
enum class UpdateCheck { IDLE, CHECKING, UP_TO_DATE, FAILED, AVAILABLE }
|
enum class UpdateCheck { IDLE, CHECKING, UP_TO_DATE, FAILED, AVAILABLE }
|
||||||
|
|
||||||
|
/** Where an update that was asked for stands. INSTALLING: the system has the build. */
|
||||||
|
enum class InstallStep { IDLE, DOWNLOADING, INSTALLING, FAILED }
|
||||||
|
|
||||||
data class AppUpdateUiState(
|
data class AppUpdateUiState(
|
||||||
val installedVersionName: String,
|
val installedVersionName: String,
|
||||||
val check: UpdateCheck = UpdateCheck.IDLE,
|
val check: UpdateCheck = UpdateCheck.IDLE,
|
||||||
@ -20,6 +24,9 @@ data class AppUpdateUiState(
|
|||||||
val available: AppRelease? = null,
|
val available: AppRelease? = null,
|
||||||
// The newer build offered on its own at launch, until it is answered.
|
// The newer build offered on its own at launch, until it is answered.
|
||||||
val prompt: AppRelease? = null,
|
val prompt: AppRelease? = null,
|
||||||
|
val install: InstallStep = InstallStep.IDLE,
|
||||||
|
// The fraction of the new build fetched so far, while DOWNLOADING.
|
||||||
|
val downloaded: Float = 0f,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Whether a newer build of the app is published, checked once at launch and again on request. */
|
/** Whether a newer build of the app is published, checked once at launch and again on request. */
|
||||||
@ -27,10 +34,12 @@ class AppUpdateViewModel(
|
|||||||
private val source: AppReleaseSource,
|
private val source: AppReleaseSource,
|
||||||
private val installedVersionCode: Int,
|
private val installedVersionCode: Int,
|
||||||
installedVersionName: String,
|
installedVersionName: String,
|
||||||
|
private val installer: AppUpdateInstaller,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val mutableState = MutableStateFlow(AppUpdateUiState(installedVersionName))
|
private val mutableState = MutableStateFlow(AppUpdateUiState(installedVersionName))
|
||||||
val state: StateFlow<AppUpdateUiState> = mutableState.asStateFlow()
|
val state: StateFlow<AppUpdateUiState> = mutableState.asStateFlow()
|
||||||
private var operation: Job? = null
|
private var operation: Job? = null
|
||||||
|
private var installation: Job? = null
|
||||||
private var checkedAtLaunch = false
|
private var checkedAtLaunch = false
|
||||||
|
|
||||||
/** The check made when the app starts: quiet unless there is something newer. */
|
/** The check made when the app starts: quiet unless there is something newer. */
|
||||||
@ -45,6 +54,27 @@ class AppUpdateViewModel(
|
|||||||
|
|
||||||
fun dismissPrompt() = mutableState.update { it.copy(prompt = null) }
|
fun dismissPrompt() = mutableState.update { it.copy(prompt = null) }
|
||||||
|
|
||||||
|
/** Fetches the newer build and installs it over this one. Answers the launch prompt too. */
|
||||||
|
fun installUpdate() {
|
||||||
|
if (installation?.isActive == true) return
|
||||||
|
mutableState.update { it.copy(prompt = null, install = InstallStep.DOWNLOADING, downloaded = 0f) }
|
||||||
|
installation = viewModelScope.launch {
|
||||||
|
val failure = installer.install(
|
||||||
|
onDownloaded = { fraction -> mutableState.update { it.copy(downloaded = fraction) } },
|
||||||
|
onHandedOver = { mutableState.update { it.copy(install = InstallStep.INSTALLING) } },
|
||||||
|
)
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(install = if (failure == InstallFailure.CANCELLED) InstallStep.IDLE else InstallStep.FAILED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops waiting for an update: one still being fetched is dropped. */
|
||||||
|
fun cancelInstall() {
|
||||||
|
installation?.cancel()
|
||||||
|
mutableState.update { it.copy(install = InstallStep.IDLE) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun check(offer: Boolean) {
|
private fun check(offer: Boolean) {
|
||||||
if (operation?.isActive == true) return
|
if (operation?.isActive == true) return
|
||||||
mutableState.update { it.copy(check = UpdateCheck.CHECKING) }
|
mutableState.update { it.copy(check = UpdateCheck.CHECKING) }
|
||||||
@ -64,11 +94,16 @@ class AppUpdateViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory : ViewModelProvider.Factory {
|
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
require(modelClass.isAssignableFrom(AppUpdateViewModel::class.java))
|
require(modelClass.isAssignableFrom(AppUpdateViewModel::class.java))
|
||||||
return AppUpdateViewModel(HttpAppReleaseSource(), BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) as T
|
return AppUpdateViewModel(
|
||||||
|
HttpAppReleaseSource(),
|
||||||
|
BuildConfig.VERSION_CODE,
|
||||||
|
BuildConfig.VERSION_NAME,
|
||||||
|
SessionAppInstaller(application),
|
||||||
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,7 +57,7 @@ fun SettingsScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
update: AppUpdateUiState? = null,
|
update: AppUpdateUiState? = null,
|
||||||
onCheckUpdate: () -> Unit = {},
|
onCheckUpdate: () -> Unit = {},
|
||||||
onOpenDownloadPage: () -> Unit = {},
|
onInstallUpdate: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Column(modifier.fillMaxSize()) {
|
Column(modifier.fillMaxSize()) {
|
||||||
@ -164,7 +164,7 @@ fun SettingsScreen(
|
|||||||
if (update != null) {
|
if (update != null) {
|
||||||
SectionTitle(stringResource(R.string.update_section), Modifier.padding(top = 8.dp))
|
SectionTitle(stringResource(R.string.update_section), Modifier.padding(top = 8.dp))
|
||||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
UpdateRow(update, onCheckUpdate, onOpenDownloadPage)
|
UpdateRow(update, onCheckUpdate, onInstallUpdate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@ -173,7 +173,7 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UpdateRow(update: AppUpdateUiState, onCheckUpdate: () -> Unit, onOpenDownloadPage: () -> Unit) {
|
private fun UpdateRow(update: AppUpdateUiState, onCheckUpdate: () -> Unit, onInstallUpdate: () -> Unit) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(stringResource(R.string.update_installed_version, update.installedVersionName), style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.update_installed_version, update.installedVersionName), style = MaterialTheme.typography.titleMedium)
|
||||||
val status = when (update.check) {
|
val status = when (update.check) {
|
||||||
@ -200,7 +200,7 @@ private fun UpdateRow(update: AppUpdateUiState, onCheckUpdate: () -> Unit, onOpe
|
|||||||
Text(stringResource(R.string.update_check))
|
Text(stringResource(R.string.update_check))
|
||||||
}
|
}
|
||||||
if (update.available != null) {
|
if (update.available != null) {
|
||||||
Button(onClick = onOpenDownloadPage, modifier = Modifier.heightIn(min = 48.dp)) {
|
Button(onClick = onInstallUpdate, modifier = Modifier.heightIn(min = 48.dp)) {
|
||||||
Text(stringResource(R.string.update_download))
|
Text(stringResource(R.string.update_download))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,18 @@
|
|||||||
<string name="update_up_to_date">최신 버전입니다.</string>
|
<string name="update_up_to_date">최신 버전입니다.</string>
|
||||||
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</string>
|
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</string>
|
||||||
<string name="update_available">새 버전 %1$s을(를) 받을 수 있습니다.</string>
|
<string name="update_available">새 버전 %1$s을(를) 받을 수 있습니다.</string>
|
||||||
<string name="update_download">새 버전 받기</string>
|
<string name="update_download">업데이트</string>
|
||||||
<string name="update_dialog_title">새 버전이 있습니다</string>
|
<string name="update_dialog_title">새 버전이 있습니다</string>
|
||||||
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n다운로드를 누르면 설치 파일을 받습니다. 받은 파일을 열어 설치하세요.</string>
|
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n업데이트를 누르면 앱이 새 버전을 받아 바로 설치합니다. 설치하는 동안 앱이 닫힙니다.</string>
|
||||||
<string name="update_dialog_download">다운로드</string>
|
<string name="update_dialog_download">업데이트</string>
|
||||||
|
<string name="update_allow_installs">이 앱의 \'앱 설치 허용\'을 켜고 돌아오면 업데이트가 시작됩니다.</string>
|
||||||
|
<string name="update_progress_title">업데이트하는 중</string>
|
||||||
|
<string name="update_progress_downloading">새 버전을 받는 중… %1$d%%</string>
|
||||||
|
<string name="update_progress_installing">설치하는 중… 설치 확인 창이 뜨면 설치를 누르세요. 끝나면 앱이 닫히니 다시 열어 주세요.</string>
|
||||||
|
<string name="update_progress_cancel">취소</string>
|
||||||
|
<string name="update_install_failed_title">업데이트하지 못했습니다</string>
|
||||||
|
<string name="update_install_failed_body">새 버전을 받거나 설치하지 못했습니다. 네트워크를 확인하고 다시 시도하세요.</string>
|
||||||
|
<string name="update_install_retry">다시 시도</string>
|
||||||
|
<string name="update_install_close">닫기</string>
|
||||||
<string name="update_dialog_later">나중에</string>
|
<string name="update_dialog_later">나중에</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
package com.ubisam.ubgitea.core.update
|
package com.ubisam.ubgitea.core.update
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
@ -15,6 +17,7 @@ import org.junit.Test
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class AppUpdateTest {
|
class AppUpdateTest {
|
||||||
|
private val noInstall = AppUpdateInstaller { _, _ -> InstallFailure.FAILED }
|
||||||
private val dispatcher = StandardTestDispatcher()
|
private val dispatcher = StandardTestDispatcher()
|
||||||
@Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
@Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||||
@After fun resetMainDispatcher() = Dispatchers.resetMain()
|
@After fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||||
@ -41,7 +44,7 @@ class AppUpdateTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `a newer build is offered at launch`() = runTest(dispatcher) {
|
fun `a newer build is offered at launch`() = runTest(dispatcher) {
|
||||||
val release = AppRelease(3, "0.3.0")
|
val release = AppRelease(3, "0.3.0")
|
||||||
val viewModel = AppUpdateViewModel({ release }, installedVersionCode = 2, installedVersionName = "0.2.0")
|
val viewModel = AppUpdateViewModel({ release }, installedVersionCode = 2, installedVersionName = "0.2.0", noInstall)
|
||||||
viewModel.checkAtLaunch()
|
viewModel.checkAtLaunch()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||||
@ -54,7 +57,7 @@ class AppUpdateTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `the launch check runs once`() = runTest(dispatcher) {
|
fun `the launch check runs once`() = runTest(dispatcher) {
|
||||||
var calls = 0
|
var calls = 0
|
||||||
val viewModel = AppUpdateViewModel({ calls++; AppRelease(3, "0.3.0") }, 2, "0.2.0")
|
val viewModel = AppUpdateViewModel({ calls++; AppRelease(3, "0.3.0") }, 2, "0.2.0", noInstall)
|
||||||
viewModel.checkAtLaunch()
|
viewModel.checkAtLaunch()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.dismissPrompt()
|
viewModel.dismissPrompt()
|
||||||
@ -66,7 +69,7 @@ class AppUpdateTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the same or an older build is up to date`() = runTest(dispatcher) {
|
fun `the same or an older build is up to date`() = runTest(dispatcher) {
|
||||||
val viewModel = AppUpdateViewModel({ AppRelease(2, "0.2.0") }, 2, "0.2.0")
|
val viewModel = AppUpdateViewModel({ AppRelease(2, "0.2.0") }, 2, "0.2.0", noInstall)
|
||||||
viewModel.checkNow()
|
viewModel.checkNow()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(UpdateCheck.UP_TO_DATE, viewModel.state.value.check)
|
assertEquals(UpdateCheck.UP_TO_DATE, viewModel.state.value.check)
|
||||||
@ -75,16 +78,52 @@ class AppUpdateTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a check by hand finds a newer build without the launch prompt`() = runTest(dispatcher) {
|
fun `a check by hand finds a newer build without the launch prompt`() = runTest(dispatcher) {
|
||||||
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0")
|
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0", noInstall)
|
||||||
viewModel.checkNow()
|
viewModel.checkNow()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||||
assertNull(viewModel.state.value.prompt)
|
assertNull(viewModel.state.value.prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an update is fetched, handed to the system and then out of the app's hands`() = runTest(dispatcher) {
|
||||||
|
val handedOver = CompletableDeferred<Unit>()
|
||||||
|
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0") { onDownloaded, onHandedOver ->
|
||||||
|
onDownloaded(0.5f)
|
||||||
|
handedOver.await()
|
||||||
|
onHandedOver()
|
||||||
|
awaitCancellation()
|
||||||
|
}
|
||||||
|
viewModel.checkAtLaunch()
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.installUpdate()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertNull(viewModel.state.value.prompt)
|
||||||
|
assertEquals(InstallStep.DOWNLOADING, viewModel.state.value.install)
|
||||||
|
assertEquals(0.5f, viewModel.state.value.downloaded)
|
||||||
|
handedOver.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(InstallStep.INSTALLING, viewModel.state.value.install)
|
||||||
|
viewModel.cancelInstall()
|
||||||
|
assertEquals(InstallStep.IDLE, viewModel.state.value.install)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an update that fails says so and one that is declined does not`() = runTest(dispatcher) {
|
||||||
|
var failure = InstallFailure.FAILED
|
||||||
|
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0") { _, _ -> failure }
|
||||||
|
viewModel.installUpdate()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(InstallStep.FAILED, viewModel.state.value.install)
|
||||||
|
failure = InstallFailure.CANCELLED
|
||||||
|
viewModel.installUpdate()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(InstallStep.IDLE, viewModel.state.value.install)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an unreadable version file is a failed check`() = runTest(dispatcher) {
|
fun `an unreadable version file is a failed check`() = runTest(dispatcher) {
|
||||||
val viewModel = AppUpdateViewModel({ null }, 2, "0.2.0")
|
val viewModel = AppUpdateViewModel({ null }, 2, "0.2.0", noInstall)
|
||||||
viewModel.checkNow()
|
viewModel.checkNow()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(UpdateCheck.FAILED, viewModel.state.value.check)
|
assertEquals(UpdateCheck.FAILED, viewModel.state.value.check)
|
||||||
|
|||||||
@ -1587,3 +1587,20 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
|||||||
`apk` branch has the same SHA-256 as the one built here.
|
`apk` branch has the same SHA-256 as the one built here.
|
||||||
- Caution: published without having been seen on a device, at the product owner's decision. The
|
- Caution: published without having been seen on a device, at the product owner's decision. The
|
||||||
APK is the debug build signed with this PC's key.
|
APK is the debug build signed with this PC's key.
|
||||||
|
|
||||||
|
## 2026-10-12 (continued) — TASK-073: the app updates itself, without a file
|
||||||
|
|
||||||
|
- Request: the product owner wanted updating without a downloaded file left on the phone.
|
||||||
|
- What changed: "업데이트" (launch prompt and settings) now fetches the APK inside the app and
|
||||||
|
gives it to the system installer. `SessionAppInstaller` streams the download straight into a
|
||||||
|
`PackageInstaller` session, so no file is written anywhere the app or the person has to clean
|
||||||
|
up. A dialog shows the percentage, can cancel the fetch, and offers a retry on failure. The
|
||||||
|
browser download is removed from the app.
|
||||||
|
- Design decision: Android 12+ is asked not to show its confirmation
|
||||||
|
(`USER_ACTION_NOT_REQUIRED`); it honours that only when this app installed the copy being
|
||||||
|
replaced, so the first update from a hand-installed copy still asks. Updating with the app
|
||||||
|
closed, and reopening it afterwards, are not possible without Play or device management.
|
||||||
|
- Permissions added: `REQUEST_INSTALL_PACKAGES`, `UPDATE_PACKAGES_WITHOUT_USER_ACTION`.
|
||||||
|
- Checks and results: the five required checks pass; two unit tests added for the update steps.
|
||||||
|
- Caution: the install path has not run on any device. It is the part of this app most likely
|
||||||
|
to differ between phones; if it fails, the APK address still installs by hand.
|
||||||
|
|||||||
67
tasks/073-update-without-a-file.md
Normal file
67
tasks/073-update-without-a-file.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# TASK-073: Update inside the app, without a downloaded file
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Owner: Claude
|
||||||
|
Reviewer: human product owner
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
The product owner asked whether the app could update itself without a file being downloaded, and
|
||||||
|
did not want a file left behind (2026-10-12). Told that Android does not allow an update with no
|
||||||
|
action at all and that the app closes while it is replaced, they asked for it to be built.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Included: "업데이트" in the launch prompt and in the settings fetches the published APK inside
|
||||||
|
the app and hands it to the system's installer; progress, cancel and failure are shown.
|
||||||
|
- The APK is streamed from the network into the system's install session. No file is written to
|
||||||
|
the downloads folder or to the app's own storage.
|
||||||
|
- From Android 12 the system is asked to skip its confirmation. It does so only once this app is
|
||||||
|
the installer of the copy being replaced, so the first update still asks.
|
||||||
|
- Excluded: updating while the app is not open; reopening the app after the update (Android does
|
||||||
|
not let an app start itself from the background).
|
||||||
|
- The browser download from TASK-070 is gone from the app. The address still works by hand.
|
||||||
|
|
||||||
|
## Writable paths
|
||||||
|
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/core/update/AppInstaller.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt`
|
||||||
|
- `app/src/main/res/values/strings_update.xml`
|
||||||
|
- `app/src/main/AndroidManifest.xml`
|
||||||
|
- `app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt`
|
||||||
|
- `tasks/073-update-without-a-file.md`
|
||||||
|
- `docs/development-log.md`
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] "업데이트" starts the fetch inside the app; no browser opens and no file is saved.
|
||||||
|
- [x] Without the "install unknown apps" allowance the person is sent to that setting and the
|
||||||
|
update starts when they return with it on.
|
||||||
|
- [x] Progress is shown, the fetch can be cancelled, and a failure can be retried.
|
||||||
|
- [x] Unit tests cover the steps of an update, a failure and a declined install.
|
||||||
|
- [ ] Seen on a device: first update (with confirmation) and a later one. Not done: no device.
|
||||||
|
|
||||||
|
## Security and privacy
|
||||||
|
|
||||||
|
- New permissions: `REQUEST_INSTALL_PACKAGES` and `UPDATE_PACKAGES_WITHOUT_USER_ACTION`.
|
||||||
|
- The APK comes over HTTPS from the fixed address of the `apk` branch; the Gitea token is not
|
||||||
|
sent. The system refuses a build that is not signed with the key of the installed copy, so a
|
||||||
|
build from another PC's debug key fails rather than replacing the app.
|
||||||
|
- The receiver for the system's answer is not exported.
|
||||||
|
|
||||||
|
## Required checks
|
||||||
|
|
||||||
|
```text
|
||||||
|
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
See the development log entry for TASK-073.
|
||||||
|
|
||||||
|
## Review findings
|
||||||
|
|
||||||
|
None.
|
||||||
Loading…
x
Reference in New Issue
Block a user