Compare commits
2 Commits
60016d1537
...
0f96a8ed76
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f96a8ed76 | |||
| 7d41e89c99 |
@ -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"
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
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.
|
||||
42
tasks/074-publish-0.2.4.md
Normal file
42
tasks/074-publish-0.2.4.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user