From 7d41e89c99825e868c149c6599eff9ccaa2a15fe Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 12 Oct 2026 14:51:08 +0900 Subject: [PATCH] TASK-073: the app fetches and installs its own update, without a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "업데이트" 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 --- app/src/main/AndroidManifest.xml | 7 + .../java/com/ubisam/ubgitea/MainActivity.kt | 77 +++++++-- .../ubgitea/core/update/AppInstaller.kt | 155 ++++++++++++++++++ .../ubisam/ubgitea/core/update/AppUpdate.kt | 2 +- .../ubgitea/core/update/AppUpdateViewModel.kt | 39 ++++- .../feature/settings/SettingsScreen.kt | 8 +- app/src/main/res/values/strings_update.xml | 15 +- .../ubgitea/core/update/AppUpdateTest.kt | 49 +++++- docs/development-log.md | 17 ++ tasks/073-update-without-a-file.md | 67 ++++++++ 10 files changed, 408 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/ubisam/ubgitea/core/update/AppInstaller.kt create mode 100644 tasks/073-update-without-a-file.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 097c688..60cb720 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ + + + + diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index 87e006f..ebec45e 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -7,6 +7,7 @@ import android.graphics.Color import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.Settings import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle @@ -25,9 +26,13 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -44,12 +49,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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.notifications.FavoritePushNotifier import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler 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.InstallStep import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryPath import com.ubisam.ubgitea.domain.model.RepositoryRef @@ -123,8 +129,12 @@ class MainActivity : ComponentActivity() { private val workbenchViewModel: CodeWorkbenchViewModel by viewModels { CodeWorkbenchViewModel.Factory(application) } + private val allowInstalls = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (packageManager.canRequestPackageInstalls()) updateViewModel.installUpdate() + } 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 @@ -523,7 +533,7 @@ class MainActivity : ComponentActivity() { onBack = { pop() }, update = updateState, onCheckUpdate = updateViewModel::checkNow, - onOpenDownloadPage = ::openDownloadPage, + onInstallUpdate = ::startUpdate, ) Route.Notifications -> NotificationsScreen( state = notificationsState, @@ -629,16 +639,52 @@ class MainActivity : ComponentActivity() { Text(if (release.notes != null) body + "\n\n" + release.notes else body) }, confirmButton = { - TextButton(onClick = { - updateViewModel.dismissPrompt() - openDownloadPage() - }) { Text(stringResource(R.string.update_dialog_download)) } + TextButton(onClick = ::startUpdate) { Text(stringResource(R.string.update_dialog_download)) } }, dismissButton = { 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() } } @@ -669,14 +715,19 @@ class MainActivity : ComponentActivity() { } } - // TASK-070: the address is the APK itself, so the browser it opens in downloads it. - private fun openDownloadPage() { + // TASK-073: the app fetches and installs the new build itself. Android asks once, in its own + // 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 { - CustomTabsIntent.Builder() - .build() - .launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD)) + allowInstalls.launch(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))) } catch (_: ActivityNotFoundException) { - Toast.makeText(this, AppUpdateLocations.DOWNLOAD, Toast.LENGTH_LONG).show() + // No such settings screen on this device: nothing more the app can do. } } diff --git a/app/src/main/java/com/ubisam/ubgitea/core/update/AppInstaller.kt b/app/src/main/java/com/ubisam/ubgitea/core/update/AppInstaller.kt new file mode 100644 index 0000000..cd5b8d9 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/update/AppInstaller.kt @@ -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(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) + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt index 1af551f..fc2ce6d 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt @@ -62,7 +62,7 @@ class HttpAppReleaseSource( } } -private val updateClient: OkHttpClient by lazy { +internal val updateClient: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) diff --git a/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt index a00178f..fd4b546 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt @@ -1,5 +1,6 @@ package com.ubisam.ubgitea.core.update +import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -13,6 +14,9 @@ import kotlinx.coroutines.launch 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( val installedVersionName: String, val check: UpdateCheck = UpdateCheck.IDLE, @@ -20,6 +24,9 @@ data class AppUpdateUiState( val available: AppRelease? = null, // The newer build offered on its own at launch, until it is answered. 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. */ @@ -27,10 +34,12 @@ class AppUpdateViewModel( private val source: AppReleaseSource, private val installedVersionCode: Int, installedVersionName: String, + private val installer: AppUpdateInstaller, ) : ViewModel() { private val mutableState = MutableStateFlow(AppUpdateUiState(installedVersionName)) val state: StateFlow = mutableState.asStateFlow() private var operation: Job? = null + private var installation: Job? = null private var checkedAtLaunch = false /** 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) } + /** 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) { if (operation?.isActive == true) return 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") override fun create(modelClass: Class): T { 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 } } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt index 72382af..d230c6a 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt @@ -57,7 +57,7 @@ fun SettingsScreen( modifier: Modifier = Modifier, update: AppUpdateUiState? = null, onCheckUpdate: () -> Unit = {}, - onOpenDownloadPage: () -> Unit = {}, + onInstallUpdate: () -> Unit = {}, ) { BackHandler(onBack = onBack) Column(modifier.fillMaxSize()) { @@ -164,7 +164,7 @@ fun SettingsScreen( if (update != null) { SectionTitle(stringResource(R.string.update_section), Modifier.padding(top = 8.dp)) ListCard(Modifier.padding(horizontal = 20.dp)) { - UpdateRow(update, onCheckUpdate, onOpenDownloadPage) + UpdateRow(update, onCheckUpdate, onInstallUpdate) } } Spacer(Modifier.height(8.dp)) @@ -173,7 +173,7 @@ fun SettingsScreen( } @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)) { Text(stringResource(R.string.update_installed_version, update.installedVersionName), style = MaterialTheme.typography.titleMedium) val status = when (update.check) { @@ -200,7 +200,7 @@ private fun UpdateRow(update: AppUpdateUiState, onCheckUpdate: () -> Unit, onOpe Text(stringResource(R.string.update_check)) } 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)) } } diff --git a/app/src/main/res/values/strings_update.xml b/app/src/main/res/values/strings_update.xml index 0984284..717a3d2 100644 --- a/app/src/main/res/values/strings_update.xml +++ b/app/src/main/res/values/strings_update.xml @@ -7,9 +7,18 @@ 최신 버전입니다. 새 버전을 확인하지 못했습니다. 네트워크를 확인하세요. 새 버전 %1$s을(를) 받을 수 있습니다. - 새 버전 받기 + 업데이트 새 버전이 있습니다 - 현재 %1$s → 새 버전 %2$s\n다운로드를 누르면 설치 파일을 받습니다. 받은 파일을 열어 설치하세요. - 다운로드 + 현재 %1$s → 새 버전 %2$s\n업데이트를 누르면 앱이 새 버전을 받아 바로 설치합니다. 설치하는 동안 앱이 닫힙니다. + 업데이트 + 이 앱의 \'앱 설치 허용\'을 켜고 돌아오면 업데이트가 시작됩니다. + 업데이트하는 중 + 새 버전을 받는 중… %1$d%% + 설치하는 중… 설치 확인 창이 뜨면 설치를 누르세요. 끝나면 앱이 닫히니 다시 열어 주세요. + 취소 + 업데이트하지 못했습니다 + 새 버전을 받거나 설치하지 못했습니다. 네트워크를 확인하고 다시 시도하세요. + 다시 시도 + 닫기 나중에 diff --git a/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt b/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt index 61573bb..4e6b2b8 100644 --- a/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt @@ -1,7 +1,9 @@ package com.ubisam.ubgitea.core.update +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -15,6 +17,7 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class AppUpdateTest { + private val noInstall = AppUpdateInstaller { _, _ -> InstallFailure.FAILED } private val dispatcher = StandardTestDispatcher() @Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher) @After fun resetMainDispatcher() = Dispatchers.resetMain() @@ -41,7 +44,7 @@ class AppUpdateTest { @Test fun `a newer build is offered at launch`() = runTest(dispatcher) { 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() advanceUntilIdle() assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check) @@ -54,7 +57,7 @@ class AppUpdateTest { @Test fun `the launch check runs once`() = runTest(dispatcher) { 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() advanceUntilIdle() viewModel.dismissPrompt() @@ -66,7 +69,7 @@ class AppUpdateTest { @Test 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() advanceUntilIdle() assertEquals(UpdateCheck.UP_TO_DATE, viewModel.state.value.check) @@ -75,16 +78,52 @@ class AppUpdateTest { @Test 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() advanceUntilIdle() assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check) 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() + 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 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() advanceUntilIdle() assertEquals(UpdateCheck.FAILED, viewModel.state.value.check) diff --git a/docs/development-log.md b/docs/development-log.md index ad1a753..854e83c 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. - 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. + +## 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. diff --git a/tasks/073-update-without-a-file.md b/tasks/073-update-without-a-file.md new file mode 100644 index 0000000..ac935c6 --- /dev/null +++ b/tasks/073-update-without-a-file.md @@ -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.