Compare commits
No commits in common. "0f96a8ed76d641ada686051aec2fde475a590088" and "60016d1537f7adb49661453e885c0916ec5d2ea4" have entirely different histories.
0f96a8ed76
...
60016d1537
@ -16,8 +16,8 @@ android {
|
|||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
// Raised for every build published to the download page (TASK-066), or the app will not
|
// Raised for every build published to the download page (TASK-066), or the app will not
|
||||||
// see it as newer.
|
// see it as newer.
|
||||||
versionCode = 6
|
versionCode = 5
|
||||||
versionName = "0.2.4"
|
versionName = "0.2.3"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,6 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- TASK-036: favorited-repository push checker's local notification. -->
|
<!-- TASK-036: favorited-repository push checker's local notification. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<!-- TASK-073: the app installs its own updates. The second lets Android 12+ skip its
|
|
||||||
confirmation once this app is the one that installed the copy being replaced. -->
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@ -34,8 +30,5 @@
|
|||||||
android:path="/ubisampaju/oauth/callback" />
|
android:path="/ubisampaju/oauth/callback" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<receiver
|
|
||||||
android:name=".core.update.AppInstallReceiver"
|
|
||||||
android:exported="false" />
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import android.graphics.Color
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.SystemBarStyle
|
import androidx.activity.SystemBarStyle
|
||||||
@ -26,13 +25,9 @@ import androidx.compose.animation.slideInHorizontally
|
|||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -49,13 +44,12 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||||
import com.ubisam.ubgitea.core.settings.ThemeMode
|
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||||
|
import com.ubisam.ubgitea.core.update.AppUpdateLocations
|
||||||
import com.ubisam.ubgitea.core.update.AppUpdateViewModel
|
import com.ubisam.ubgitea.core.update.AppUpdateViewModel
|
||||||
import com.ubisam.ubgitea.core.update.InstallStep
|
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
@ -129,12 +123,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
|
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
|
||||||
CodeWorkbenchViewModel.Factory(application)
|
CodeWorkbenchViewModel.Factory(application)
|
||||||
}
|
}
|
||||||
private val allowInstalls =
|
|
||||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
if (packageManager.canRequestPackageInstalls()) updateViewModel.installUpdate()
|
|
||||||
}
|
|
||||||
private val updateViewModel: AppUpdateViewModel by viewModels {
|
private val updateViewModel: AppUpdateViewModel by viewModels {
|
||||||
AppUpdateViewModel.Factory(application)
|
AppUpdateViewModel.Factory()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
|
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
|
||||||
@ -533,7 +523,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
update = updateState,
|
update = updateState,
|
||||||
onCheckUpdate = updateViewModel::checkNow,
|
onCheckUpdate = updateViewModel::checkNow,
|
||||||
onInstallUpdate = ::startUpdate,
|
onOpenDownloadPage = ::openDownloadPage,
|
||||||
)
|
)
|
||||||
Route.Notifications -> NotificationsScreen(
|
Route.Notifications -> NotificationsScreen(
|
||||||
state = notificationsState,
|
state = notificationsState,
|
||||||
@ -639,52 +629,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
Text(if (release.notes != null) body + "\n\n" + release.notes else body)
|
Text(if (release.notes != null) body + "\n\n" + release.notes else body)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = ::startUpdate) { Text(stringResource(R.string.update_dialog_download)) }
|
TextButton(onClick = {
|
||||||
|
updateViewModel.dismissPrompt()
|
||||||
|
openDownloadPage()
|
||||||
|
}) { Text(stringResource(R.string.update_dialog_download)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = updateViewModel::dismissPrompt) { Text(stringResource(R.string.update_dialog_later)) }
|
TextButton(onClick = updateViewModel::dismissPrompt) { Text(stringResource(R.string.update_dialog_later)) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (updateState.install) {
|
|
||||||
InstallStep.IDLE -> Unit
|
|
||||||
InstallStep.DOWNLOADING, InstallStep.INSTALLING -> {
|
|
||||||
val downloading = updateState.install == InstallStep.DOWNLOADING
|
|
||||||
AlertDialog(
|
|
||||||
// Only its button closes it: a stray tap should not drop a download.
|
|
||||||
onDismissRequest = {},
|
|
||||||
title = { Text(stringResource(R.string.update_progress_title)) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
|
||||||
if (downloading) {
|
|
||||||
val fraction = updateState.downloaded
|
|
||||||
Text(stringResource(R.string.update_progress_downloading, (fraction * 100).toInt()))
|
|
||||||
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth())
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.update_progress_installing))
|
|
||||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = updateViewModel::cancelInstall) {
|
|
||||||
Text(stringResource(if (downloading) R.string.update_progress_cancel else R.string.update_install_close))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
InstallStep.FAILED -> AlertDialog(
|
|
||||||
onDismissRequest = updateViewModel::cancelInstall,
|
|
||||||
title = { Text(stringResource(R.string.update_install_failed_title)) },
|
|
||||||
text = { Text(stringResource(R.string.update_install_failed_body)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = ::startUpdate) { Text(stringResource(R.string.update_install_retry)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = updateViewModel::cancelInstall) { Text(stringResource(R.string.update_install_close)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SplashOverlay()
|
SplashOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -715,19 +669,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TASK-073: the app fetches and installs the new build itself. Android asks once, in its own
|
// TASK-070: the address is the APK itself, so the browser it opens in downloads it.
|
||||||
// settings, whether this app may install apps; the update starts when the person comes back.
|
private fun openDownloadPage() {
|
||||||
private fun startUpdate() {
|
|
||||||
if (packageManager.canRequestPackageInstalls()) {
|
|
||||||
updateViewModel.installUpdate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateViewModel.dismissPrompt()
|
|
||||||
Toast.makeText(this, R.string.update_allow_installs, Toast.LENGTH_LONG).show()
|
|
||||||
try {
|
try {
|
||||||
allowInstalls.launch(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")))
|
CustomTabsIntent.Builder()
|
||||||
|
.build()
|
||||||
|
.launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD))
|
||||||
} catch (_: ActivityNotFoundException) {
|
} catch (_: ActivityNotFoundException) {
|
||||||
// No such settings screen on this device: nothing more the app can do.
|
Toast.makeText(this, AppUpdateLocations.DOWNLOAD, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,155 +0,0 @@
|
|||||||
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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val updateClient: OkHttpClient by lazy {
|
private val updateClient: OkHttpClient by lazy {
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
.readTimeout(10, TimeUnit.SECONDS)
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package com.ubisam.ubgitea.core.update
|
package com.ubisam.ubgitea.core.update
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@ -14,9 +13,6 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
enum class UpdateCheck { IDLE, CHECKING, UP_TO_DATE, FAILED, AVAILABLE }
|
enum class UpdateCheck { IDLE, CHECKING, UP_TO_DATE, FAILED, AVAILABLE }
|
||||||
|
|
||||||
/** Where an update that was asked for stands. INSTALLING: the system has the build. */
|
|
||||||
enum class InstallStep { IDLE, DOWNLOADING, INSTALLING, FAILED }
|
|
||||||
|
|
||||||
data class AppUpdateUiState(
|
data class AppUpdateUiState(
|
||||||
val installedVersionName: String,
|
val installedVersionName: String,
|
||||||
val check: UpdateCheck = UpdateCheck.IDLE,
|
val check: UpdateCheck = UpdateCheck.IDLE,
|
||||||
@ -24,9 +20,6 @@ data class AppUpdateUiState(
|
|||||||
val available: AppRelease? = null,
|
val available: AppRelease? = null,
|
||||||
// The newer build offered on its own at launch, until it is answered.
|
// The newer build offered on its own at launch, until it is answered.
|
||||||
val prompt: AppRelease? = null,
|
val prompt: AppRelease? = null,
|
||||||
val install: InstallStep = InstallStep.IDLE,
|
|
||||||
// The fraction of the new build fetched so far, while DOWNLOADING.
|
|
||||||
val downloaded: Float = 0f,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Whether a newer build of the app is published, checked once at launch and again on request. */
|
/** Whether a newer build of the app is published, checked once at launch and again on request. */
|
||||||
@ -34,12 +27,10 @@ class AppUpdateViewModel(
|
|||||||
private val source: AppReleaseSource,
|
private val source: AppReleaseSource,
|
||||||
private val installedVersionCode: Int,
|
private val installedVersionCode: Int,
|
||||||
installedVersionName: String,
|
installedVersionName: String,
|
||||||
private val installer: AppUpdateInstaller,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val mutableState = MutableStateFlow(AppUpdateUiState(installedVersionName))
|
private val mutableState = MutableStateFlow(AppUpdateUiState(installedVersionName))
|
||||||
val state: StateFlow<AppUpdateUiState> = mutableState.asStateFlow()
|
val state: StateFlow<AppUpdateUiState> = mutableState.asStateFlow()
|
||||||
private var operation: Job? = null
|
private var operation: Job? = null
|
||||||
private var installation: Job? = null
|
|
||||||
private var checkedAtLaunch = false
|
private var checkedAtLaunch = false
|
||||||
|
|
||||||
/** The check made when the app starts: quiet unless there is something newer. */
|
/** The check made when the app starts: quiet unless there is something newer. */
|
||||||
@ -54,27 +45,6 @@ class AppUpdateViewModel(
|
|||||||
|
|
||||||
fun dismissPrompt() = mutableState.update { it.copy(prompt = null) }
|
fun dismissPrompt() = mutableState.update { it.copy(prompt = null) }
|
||||||
|
|
||||||
/** Fetches the newer build and installs it over this one. Answers the launch prompt too. */
|
|
||||||
fun installUpdate() {
|
|
||||||
if (installation?.isActive == true) return
|
|
||||||
mutableState.update { it.copy(prompt = null, install = InstallStep.DOWNLOADING, downloaded = 0f) }
|
|
||||||
installation = viewModelScope.launch {
|
|
||||||
val failure = installer.install(
|
|
||||||
onDownloaded = { fraction -> mutableState.update { it.copy(downloaded = fraction) } },
|
|
||||||
onHandedOver = { mutableState.update { it.copy(install = InstallStep.INSTALLING) } },
|
|
||||||
)
|
|
||||||
mutableState.update {
|
|
||||||
it.copy(install = if (failure == InstallFailure.CANCELLED) InstallStep.IDLE else InstallStep.FAILED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stops waiting for an update: one still being fetched is dropped. */
|
|
||||||
fun cancelInstall() {
|
|
||||||
installation?.cancel()
|
|
||||||
mutableState.update { it.copy(install = InstallStep.IDLE) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun check(offer: Boolean) {
|
private fun check(offer: Boolean) {
|
||||||
if (operation?.isActive == true) return
|
if (operation?.isActive == true) return
|
||||||
mutableState.update { it.copy(check = UpdateCheck.CHECKING) }
|
mutableState.update { it.copy(check = UpdateCheck.CHECKING) }
|
||||||
@ -94,16 +64,11 @@ class AppUpdateViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
class Factory : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
require(modelClass.isAssignableFrom(AppUpdateViewModel::class.java))
|
require(modelClass.isAssignableFrom(AppUpdateViewModel::class.java))
|
||||||
return AppUpdateViewModel(
|
return AppUpdateViewModel(HttpAppReleaseSource(), BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) as T
|
||||||
HttpAppReleaseSource(),
|
|
||||||
BuildConfig.VERSION_CODE,
|
|
||||||
BuildConfig.VERSION_NAME,
|
|
||||||
SessionAppInstaller(application),
|
|
||||||
) as T
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,7 +57,7 @@ fun SettingsScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
update: AppUpdateUiState? = null,
|
update: AppUpdateUiState? = null,
|
||||||
onCheckUpdate: () -> Unit = {},
|
onCheckUpdate: () -> Unit = {},
|
||||||
onInstallUpdate: () -> Unit = {},
|
onOpenDownloadPage: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Column(modifier.fillMaxSize()) {
|
Column(modifier.fillMaxSize()) {
|
||||||
@ -164,7 +164,7 @@ fun SettingsScreen(
|
|||||||
if (update != null) {
|
if (update != null) {
|
||||||
SectionTitle(stringResource(R.string.update_section), Modifier.padding(top = 8.dp))
|
SectionTitle(stringResource(R.string.update_section), Modifier.padding(top = 8.dp))
|
||||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||||
UpdateRow(update, onCheckUpdate, onInstallUpdate)
|
UpdateRow(update, onCheckUpdate, onOpenDownloadPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@ -173,7 +173,7 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UpdateRow(update: AppUpdateUiState, onCheckUpdate: () -> Unit, onInstallUpdate: () -> Unit) {
|
private fun UpdateRow(update: AppUpdateUiState, onCheckUpdate: () -> Unit, onOpenDownloadPage: () -> Unit) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(stringResource(R.string.update_installed_version, update.installedVersionName), style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.update_installed_version, update.installedVersionName), style = MaterialTheme.typography.titleMedium)
|
||||||
val status = when (update.check) {
|
val status = when (update.check) {
|
||||||
@ -200,7 +200,7 @@ private fun UpdateRow(update: AppUpdateUiState, onCheckUpdate: () -> Unit, onIns
|
|||||||
Text(stringResource(R.string.update_check))
|
Text(stringResource(R.string.update_check))
|
||||||
}
|
}
|
||||||
if (update.available != null) {
|
if (update.available != null) {
|
||||||
Button(onClick = onInstallUpdate, modifier = Modifier.heightIn(min = 48.dp)) {
|
Button(onClick = onOpenDownloadPage, modifier = Modifier.heightIn(min = 48.dp)) {
|
||||||
Text(stringResource(R.string.update_download))
|
Text(stringResource(R.string.update_download))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,18 +7,9 @@
|
|||||||
<string name="update_up_to_date">최신 버전입니다.</string>
|
<string name="update_up_to_date">최신 버전입니다.</string>
|
||||||
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</string>
|
<string name="update_failed">새 버전을 확인하지 못했습니다. 네트워크를 확인하세요.</string>
|
||||||
<string name="update_available">새 버전 %1$s을(를) 받을 수 있습니다.</string>
|
<string name="update_available">새 버전 %1$s을(를) 받을 수 있습니다.</string>
|
||||||
<string name="update_download">업데이트</string>
|
<string name="update_download">새 버전 받기</string>
|
||||||
<string name="update_dialog_title">새 버전이 있습니다</string>
|
<string name="update_dialog_title">새 버전이 있습니다</string>
|
||||||
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n업데이트를 누르면 앱이 새 버전을 받아 바로 설치합니다. 설치하는 동안 앱이 닫힙니다.</string>
|
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n다운로드를 누르면 설치 파일을 받습니다. 받은 파일을 열어 설치하세요.</string>
|
||||||
<string name="update_dialog_download">업데이트</string>
|
<string name="update_dialog_download">다운로드</string>
|
||||||
<string name="update_allow_installs">이 앱의 \'앱 설치 허용\'을 켜고 돌아오면 업데이트가 시작됩니다.</string>
|
|
||||||
<string name="update_progress_title">업데이트하는 중</string>
|
|
||||||
<string name="update_progress_downloading">새 버전을 받는 중… %1$d%%</string>
|
|
||||||
<string name="update_progress_installing">설치하는 중… 설치 확인 창이 뜨면 설치를 누르세요. 끝나면 앱이 닫히니 다시 열어 주세요.</string>
|
|
||||||
<string name="update_progress_cancel">취소</string>
|
|
||||||
<string name="update_install_failed_title">업데이트하지 못했습니다</string>
|
|
||||||
<string name="update_install_failed_body">새 버전을 받거나 설치하지 못했습니다. 네트워크를 확인하고 다시 시도하세요.</string>
|
|
||||||
<string name="update_install_retry">다시 시도</string>
|
|
||||||
<string name="update_install_close">닫기</string>
|
|
||||||
<string name="update_dialog_later">나중에</string>
|
<string name="update_dialog_later">나중에</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
package com.ubisam.ubgitea.core.update
|
package com.ubisam.ubgitea.core.update
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.awaitCancellation
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
@ -17,7 +15,6 @@ import org.junit.Test
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class AppUpdateTest {
|
class AppUpdateTest {
|
||||||
private val noInstall = AppUpdateInstaller { _, _ -> InstallFailure.FAILED }
|
|
||||||
private val dispatcher = StandardTestDispatcher()
|
private val dispatcher = StandardTestDispatcher()
|
||||||
@Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
@Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||||
@After fun resetMainDispatcher() = Dispatchers.resetMain()
|
@After fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||||
@ -44,7 +41,7 @@ class AppUpdateTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `a newer build is offered at launch`() = runTest(dispatcher) {
|
fun `a newer build is offered at launch`() = runTest(dispatcher) {
|
||||||
val release = AppRelease(3, "0.3.0")
|
val release = AppRelease(3, "0.3.0")
|
||||||
val viewModel = AppUpdateViewModel({ release }, installedVersionCode = 2, installedVersionName = "0.2.0", noInstall)
|
val viewModel = AppUpdateViewModel({ release }, installedVersionCode = 2, installedVersionName = "0.2.0")
|
||||||
viewModel.checkAtLaunch()
|
viewModel.checkAtLaunch()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||||
@ -57,7 +54,7 @@ class AppUpdateTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `the launch check runs once`() = runTest(dispatcher) {
|
fun `the launch check runs once`() = runTest(dispatcher) {
|
||||||
var calls = 0
|
var calls = 0
|
||||||
val viewModel = AppUpdateViewModel({ calls++; AppRelease(3, "0.3.0") }, 2, "0.2.0", noInstall)
|
val viewModel = AppUpdateViewModel({ calls++; AppRelease(3, "0.3.0") }, 2, "0.2.0")
|
||||||
viewModel.checkAtLaunch()
|
viewModel.checkAtLaunch()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.dismissPrompt()
|
viewModel.dismissPrompt()
|
||||||
@ -69,7 +66,7 @@ class AppUpdateTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the same or an older build is up to date`() = runTest(dispatcher) {
|
fun `the same or an older build is up to date`() = runTest(dispatcher) {
|
||||||
val viewModel = AppUpdateViewModel({ AppRelease(2, "0.2.0") }, 2, "0.2.0", noInstall)
|
val viewModel = AppUpdateViewModel({ AppRelease(2, "0.2.0") }, 2, "0.2.0")
|
||||||
viewModel.checkNow()
|
viewModel.checkNow()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(UpdateCheck.UP_TO_DATE, viewModel.state.value.check)
|
assertEquals(UpdateCheck.UP_TO_DATE, viewModel.state.value.check)
|
||||||
@ -78,52 +75,16 @@ class AppUpdateTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a check by hand finds a newer build without the launch prompt`() = runTest(dispatcher) {
|
fun `a check by hand finds a newer build without the launch prompt`() = runTest(dispatcher) {
|
||||||
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0", noInstall)
|
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0")
|
||||||
viewModel.checkNow()
|
viewModel.checkNow()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||||
assertNull(viewModel.state.value.prompt)
|
assertNull(viewModel.state.value.prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an update is fetched, handed to the system and then out of the app's hands`() = runTest(dispatcher) {
|
|
||||||
val handedOver = CompletableDeferred<Unit>()
|
|
||||||
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0") { onDownloaded, onHandedOver ->
|
|
||||||
onDownloaded(0.5f)
|
|
||||||
handedOver.await()
|
|
||||||
onHandedOver()
|
|
||||||
awaitCancellation()
|
|
||||||
}
|
|
||||||
viewModel.checkAtLaunch()
|
|
||||||
advanceUntilIdle()
|
|
||||||
viewModel.installUpdate()
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertNull(viewModel.state.value.prompt)
|
|
||||||
assertEquals(InstallStep.DOWNLOADING, viewModel.state.value.install)
|
|
||||||
assertEquals(0.5f, viewModel.state.value.downloaded)
|
|
||||||
handedOver.complete(Unit)
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertEquals(InstallStep.INSTALLING, viewModel.state.value.install)
|
|
||||||
viewModel.cancelInstall()
|
|
||||||
assertEquals(InstallStep.IDLE, viewModel.state.value.install)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an update that fails says so and one that is declined does not`() = runTest(dispatcher) {
|
|
||||||
var failure = InstallFailure.FAILED
|
|
||||||
val viewModel = AppUpdateViewModel({ AppRelease(3, "0.3.0") }, 2, "0.2.0") { _, _ -> failure }
|
|
||||||
viewModel.installUpdate()
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertEquals(InstallStep.FAILED, viewModel.state.value.install)
|
|
||||||
failure = InstallFailure.CANCELLED
|
|
||||||
viewModel.installUpdate()
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertEquals(InstallStep.IDLE, viewModel.state.value.install)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an unreadable version file is a failed check`() = runTest(dispatcher) {
|
fun `an unreadable version file is a failed check`() = runTest(dispatcher) {
|
||||||
val viewModel = AppUpdateViewModel({ null }, 2, "0.2.0", noInstall)
|
val viewModel = AppUpdateViewModel({ null }, 2, "0.2.0")
|
||||||
viewModel.checkNow()
|
viewModel.checkNow()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(UpdateCheck.FAILED, viewModel.state.value.check)
|
assertEquals(UpdateCheck.FAILED, viewModel.state.value.check)
|
||||||
|
|||||||
@ -1587,31 +1587,3 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
|||||||
`apk` branch has the same SHA-256 as the one built here.
|
`apk` branch has the same SHA-256 as the one built here.
|
||||||
- Caution: published without having been seen on a device, at the product owner's decision. The
|
- Caution: published without having been seen on a device, at the product owner's decision. The
|
||||||
APK is the debug build signed with this PC's key.
|
APK is the debug build signed with this PC's key.
|
||||||
|
|
||||||
## 2026-10-12 (continued) — TASK-073: the app updates itself, without a file
|
|
||||||
|
|
||||||
- Request: the product owner wanted updating without a downloaded file left on the phone.
|
|
||||||
- What changed: "업데이트" (launch prompt and settings) now fetches the APK inside the app and
|
|
||||||
gives it to the system installer. `SessionAppInstaller` streams the download straight into a
|
|
||||||
`PackageInstaller` session, so no file is written anywhere the app or the person has to clean
|
|
||||||
up. A dialog shows the percentage, can cancel the fetch, and offers a retry on failure. The
|
|
||||||
browser download is removed from the app.
|
|
||||||
- Design decision: Android 12+ is asked not to show its confirmation
|
|
||||||
(`USER_ACTION_NOT_REQUIRED`); it honours that only when this app installed the copy being
|
|
||||||
replaced, so the first update from a hand-installed copy still asks. Updating with the app
|
|
||||||
closed, and reopening it afterwards, are not possible without Play or device management.
|
|
||||||
- Permissions added: `REQUEST_INSTALL_PACKAGES`, `UPDATE_PACKAGES_WITHOUT_USER_ACTION`.
|
|
||||||
- Checks and results: the five required checks pass; two unit tests added for the update steps.
|
|
||||||
- Caution: the install path has not run on any device. It is the part of this app most likely
|
|
||||||
to differ between phones; if it fails, the APK address still installs by hand.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
# 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