Compare commits

...

2 Commits

Author SHA1 Message Date
0f96a8ed76 TASK-074: version 0.2.4 for the build published to the team's Gitea
Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:54:09 +09:00
7d41e89c99 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>
2026-10-12 14:51:08 +09:00
12 changed files with 463 additions and 30 deletions

View File

@ -16,8 +16,8 @@ android {
targetSdk = 36
// Raised for every build published to the download page (TASK-066), or the app will not
// see it as newer.
versionCode = 5
versionName = "0.2.3"
versionCode = 6
versionName = "0.2.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@ -3,6 +3,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<!-- TASK-036: favorited-repository push checker's local notification. -->
<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
android:allowBackup="false"
@ -30,5 +34,8 @@
android:path="/ubisampaju/oauth/callback" />
</intent-filter>
</activity>
<receiver
android:name=".core.update.AppInstallReceiver"
android:exported="false" />
</application>
</manifest>

View File

@ -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.
}
}

View 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)
}
}
}

View File

@ -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)

View File

@ -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<AppUpdateUiState> = 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 <T : ViewModel> create(modelClass: Class<T>): 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
}
}
}

View File

@ -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))
}
}

View File

@ -7,9 +7,18 @@
<string name="update_up_to_date">최신 버전입니다.</string>
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</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_body">현재 %1$s → 새 버전 %2$s\n다운로드를 누르면 설치 파일을 받습니다. 받은 파일을 열어 설치하세요.</string>
<string name="update_dialog_download">다운로드</string>
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n업데이트를 누르면 앱이 새 버전을 받아 바로 설치합니다. 설치하는 동안 앱이 닫힙니다.</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>
</resources>

View File

@ -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<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
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)

View File

@ -1587,3 +1587,31 @@ 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.
## 2026-10-12 (continued) — TASK-074: 0.2.4 published to the team's Gitea
- Request: the product owner asked for 0.2.4 to be published.
- What changed: version 0.2.4 (versionCode 6) with the in-app update of TASK-073. Source pushed
to `main`; `UBGitea.apk` and `version.json` on the `apk` branch replaced.
- Checks and results: the five required checks pass; lint 0 errors. The APK downloaded from the
`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.
0.2.3 and older still update through the browser download; the in-app update is first used
going from 0.2.4 to whatever comes next, so it stays unproven until then.

View 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.

View File

@ -0,0 +1,42 @@
# TASK-074: Publish 0.2.4 to the team's Gitea
Status: done
Owner: Claude
Reviewer: human product owner
## Request
The product owner asked for the update without a downloaded file (TASK-073) to be published as
0.2.4 (2026-10-12).
## Writable paths
- `app/build.gradle.kts`
- `tasks/074-publish-0.2.4.md`
- `docs/development-log.md`
## Acceptance criteria
- [x] Version 0.2.4, versionCode 6.
- [x] The source is on `main` of the team's repository.
- [x] `UBGitea.apk` and `version.json` on the `apk` branch are 0.2.4, and the downloaded APK is
byte-identical to the one built here.
## Security and privacy
The APK is the debug build, signed with this PC's debug key, as before. No secret is in it or in
the push.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
See the development log entry for TASK-074.
## Review findings
None.