TASK-066: download page and in-app update check
- version 0.2.0 (versionCode 2) - The app reads version.json from the download page once at launch and offers the page when a newer build is out; settings shows the version and a check button. - tools/publish-download.py encrypts an APK under the download password and writes the page that decrypts it in the browser. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
54530096e0
commit
69556b1e23
@ -14,8 +14,10 @@ android {
|
||||
applicationId = "com.ubisam.ubgitea"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// Raised for every build published to the download page (TASK-066), or the app will not
|
||||
// see it as newer.
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@ -30,6 +30,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||
import com.ubisam.ubgitea.core.update.AppUpdateLocations
|
||||
import com.ubisam.ubgitea.core.update.AppUpdateViewModel
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
@ -103,6 +109,9 @@ class MainActivity : ComponentActivity() {
|
||||
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
|
||||
CodeWorkbenchViewModel.Factory(application)
|
||||
}
|
||||
private val updateViewModel: AppUpdateViewModel by viewModels {
|
||||
AppUpdateViewModel.Factory()
|
||||
}
|
||||
|
||||
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
|
||||
// whether to actually show a prompt, this call is safe to make on every launch. Must be
|
||||
@ -134,6 +143,9 @@ class MainActivity : ComponentActivity() {
|
||||
val homeState by homeViewModel.state.collectAsState()
|
||||
val workState by workViewModel.state.collectAsState()
|
||||
val workbenchState by workbenchViewModel.state.collectAsState()
|
||||
val updateState by updateViewModel.state.collectAsState()
|
||||
// TASK-066: whether a newer build is published, once per launch.
|
||||
LaunchedEffect(Unit) { updateViewModel.checkAtLaunch() }
|
||||
|
||||
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
|
||||
mutableStateOf(listOf<Route>(Route.Home))
|
||||
@ -465,6 +477,9 @@ class MainActivity : ComponentActivity() {
|
||||
onSetThemeMode = settingsViewModel::setThemeMode,
|
||||
onLogout = ::signOut,
|
||||
onBack = { pop() },
|
||||
update = updateState,
|
||||
onCheckUpdate = updateViewModel::checkNow,
|
||||
onOpenDownloadPage = ::openDownloadPage,
|
||||
)
|
||||
Route.Notifications -> NotificationsScreen(
|
||||
state = notificationsState,
|
||||
@ -559,6 +574,25 @@ class MainActivity : ComponentActivity() {
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.browserRequests.collect(::openAuthorizationTab)
|
||||
}
|
||||
updateState.prompt?.let { release ->
|
||||
AlertDialog(
|
||||
onDismissRequest = updateViewModel::dismissPrompt,
|
||||
title = { Text(stringResource(R.string.update_dialog_title)) },
|
||||
text = {
|
||||
val body = stringResource(R.string.update_dialog_body, updateState.installedVersionName, release.versionName)
|
||||
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)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = updateViewModel::dismissPrompt) { Text(stringResource(R.string.update_dialog_later)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
consumeCallback(intent)
|
||||
@ -588,6 +622,17 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// TASK-066: the download page asks for its password itself, so it opens in the browser.
|
||||
private fun openDownloadPage() {
|
||||
try {
|
||||
CustomTabsIntent.Builder()
|
||||
.build()
|
||||
.launchUrl(this, Uri.parse(AppUpdateLocations.DOWNLOAD_PAGE))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(this, AppUpdateLocations.DOWNLOAD_PAGE, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeCallback(intent: Intent?) {
|
||||
val callback = intent?.dataString ?: return
|
||||
setIntent(Intent(intent).setData(null))
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
package com.ubisam.ubgitea.core.update
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Where new builds of the app are published (TASK-066): a page that hands out the APK behind a
|
||||
* password, and beside it a plain file naming the newest version. The app only ever reads that
|
||||
* file; no Gitea token goes there.
|
||||
*/
|
||||
object AppUpdateLocations {
|
||||
const val DOWNLOAD_PAGE = "https://choimy0204.github.io/ubgitea/"
|
||||
const val LATEST_VERSION = DOWNLOAD_PAGE + "version.json"
|
||||
}
|
||||
|
||||
/** The newest published build. */
|
||||
data class AppRelease(
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val notes: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reads `{"versionCode": 2, "versionName": "0.2.0", "notes": "..."}`. Anything else is null, so
|
||||
* a page that is broken or not there yet never offers an update.
|
||||
*/
|
||||
fun parseAppRelease(json: String): AppRelease? = try {
|
||||
val root = JsonParser.parseString(json).asJsonObject
|
||||
val code = root.get("versionCode")?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asInt
|
||||
val name = root.get("versionName")?.takeIf { it.isJsonPrimitive }?.asString?.trim()
|
||||
val notes = root.get("notes")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifEmpty { null }
|
||||
if (code == null || code <= 0 || name.isNullOrEmpty()) null else AppRelease(code, name, notes)
|
||||
} catch (_: RuntimeException) {
|
||||
null
|
||||
}
|
||||
|
||||
fun interface AppReleaseSource {
|
||||
/** The newest published build, or null when it cannot be read. */
|
||||
suspend fun latest(): AppRelease?
|
||||
}
|
||||
|
||||
class HttpAppReleaseSource(
|
||||
private val url: String = AppUpdateLocations.LATEST_VERSION,
|
||||
private val client: OkHttpClient = updateClient,
|
||||
) : AppReleaseSource {
|
||||
override suspend fun latest(): AppRelease? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// no-cache: GitHub Pages is cached for minutes, and a check by hand should see a release at once.
|
||||
val request = Request.Builder().url(url).header("Cache-Control", "no-cache").build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) null else parseAppRelease(response.body.string())
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val updateClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package com.ubisam.ubgitea.core.update
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ubisam.ubgitea.BuildConfig
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class UpdateCheck { IDLE, CHECKING, UP_TO_DATE, FAILED, AVAILABLE }
|
||||
|
||||
data class AppUpdateUiState(
|
||||
val installedVersionName: String,
|
||||
val check: UpdateCheck = UpdateCheck.IDLE,
|
||||
// Set when a newer build than this one is published.
|
||||
val available: AppRelease? = null,
|
||||
// The newer build offered on its own at launch, until it is answered.
|
||||
val prompt: AppRelease? = null,
|
||||
)
|
||||
|
||||
/** Whether a newer build of the app is published, checked once at launch and again on request. */
|
||||
class AppUpdateViewModel(
|
||||
private val source: AppReleaseSource,
|
||||
private val installedVersionCode: Int,
|
||||
installedVersionName: String,
|
||||
) : ViewModel() {
|
||||
private val mutableState = MutableStateFlow(AppUpdateUiState(installedVersionName))
|
||||
val state: StateFlow<AppUpdateUiState> = mutableState.asStateFlow()
|
||||
private var operation: Job? = null
|
||||
private var checkedAtLaunch = false
|
||||
|
||||
/** The check made when the app starts: quiet unless there is something newer. */
|
||||
fun checkAtLaunch() {
|
||||
if (checkedAtLaunch) return
|
||||
checkedAtLaunch = true
|
||||
check(offer = true)
|
||||
}
|
||||
|
||||
/** A check asked for by hand, as from the settings. */
|
||||
fun checkNow() = check(offer = false)
|
||||
|
||||
fun dismissPrompt() = mutableState.update { it.copy(prompt = null) }
|
||||
|
||||
private fun check(offer: Boolean) {
|
||||
if (operation?.isActive == true) return
|
||||
mutableState.update { it.copy(check = UpdateCheck.CHECKING) }
|
||||
operation = viewModelScope.launch {
|
||||
val latest = source.latest()
|
||||
mutableState.update { state ->
|
||||
when {
|
||||
latest == null -> state.copy(check = UpdateCheck.FAILED)
|
||||
latest.versionCode > installedVersionCode -> state.copy(
|
||||
check = UpdateCheck.AVAILABLE,
|
||||
available = latest,
|
||||
prompt = if (offer) latest else state.prompt,
|
||||
)
|
||||
else -> state.copy(check = UpdateCheck.UP_TO_DATE, available = null, prompt = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory : 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -24,12 +25,17 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ubisam.ubgitea.R
|
||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||
import com.ubisam.ubgitea.core.settings.ThemeMode
|
||||
import com.ubisam.ubgitea.core.update.AppUpdateUiState
|
||||
import com.ubisam.ubgitea.core.update.UpdateCheck
|
||||
import com.ubisam.ubgitea.ui.components.Avatar
|
||||
import com.ubisam.ubgitea.ui.components.ListCard
|
||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||
@ -49,6 +55,9 @@ fun SettingsScreen(
|
||||
onLogout: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
update: AppUpdateUiState? = null,
|
||||
onCheckUpdate: () -> Unit = {},
|
||||
onOpenDownloadPage: () -> Unit = {},
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
Column(modifier.fillMaxSize()) {
|
||||
@ -152,11 +161,53 @@ fun SettingsScreen(
|
||||
onCheckedChange = onSetFavoritePushNotificationsEnabled,
|
||||
)
|
||||
}
|
||||
if (update != null) {
|
||||
SectionTitle(stringResource(R.string.update_section), Modifier.padding(top = 8.dp))
|
||||
ListCard(Modifier.padding(horizontal = 20.dp)) {
|
||||
UpdateRow(update, onCheckUpdate, onOpenDownloadPage)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateRow(update: AppUpdateUiState, onCheckUpdate: () -> Unit, onOpenDownloadPage: () -> 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) {
|
||||
UpdateCheck.IDLE -> null
|
||||
UpdateCheck.CHECKING -> stringResource(R.string.update_checking)
|
||||
UpdateCheck.UP_TO_DATE -> stringResource(R.string.update_up_to_date)
|
||||
UpdateCheck.FAILED -> stringResource(R.string.update_failed)
|
||||
UpdateCheck.AVAILABLE -> update.available?.let { stringResource(R.string.update_available, it.versionName) }
|
||||
}
|
||||
if (status != null) {
|
||||
Text(
|
||||
status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (update.check == UpdateCheck.AVAILABLE) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = onCheckUpdate,
|
||||
enabled = update.check != UpdateCheck.CHECKING,
|
||||
modifier = Modifier.heightIn(min = 48.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.update_check))
|
||||
}
|
||||
if (update.available != null) {
|
||||
Button(onClick = onOpenDownloadPage, modifier = Modifier.heightIn(min = 48.dp)) {
|
||||
Text(stringResource(R.string.update_download))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ThemeMode.label: Int
|
||||
get() = when (this) {
|
||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||
|
||||
15
app/src/main/res/values/strings_update.xml
Normal file
15
app/src/main/res/values/strings_update.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="update_section">앱 정보</string>
|
||||
<string name="update_installed_version">현재 버전 %1$s</string>
|
||||
<string name="update_check">업데이트 확인</string>
|
||||
<string name="update_checking">확인하는 중…</string>
|
||||
<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_dialog_title">새 버전이 있습니다</string>
|
||||
<string name="update_dialog_body">현재 %1$s → 새 버전 %2$s\n다운로드 페이지에서 비밀번호를 입력해 받은 뒤 설치하세요.</string>
|
||||
<string name="update_dialog_download">다운로드</string>
|
||||
<string name="update_dialog_later">나중에</string>
|
||||
</resources>
|
||||
@ -0,0 +1,92 @@
|
||||
package com.ubisam.ubgitea.core.update
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AppUpdateTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
@Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||
@After fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||
|
||||
@Test
|
||||
fun `a version file is read`() {
|
||||
assertEquals(
|
||||
AppRelease(3, "0.3.0", "탭 저장"),
|
||||
parseAppRelease("""{"versionCode": 3, "versionName": " 0.3.0 ", "notes": "탭 저장"}"""),
|
||||
)
|
||||
assertEquals(AppRelease(3, "0.3.0"), parseAppRelease("""{"versionCode": 3, "versionName": "0.3.0", "notes": ""}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a broken or incomplete version file is no release`() {
|
||||
assertNull(parseAppRelease("<html>404</html>"))
|
||||
assertNull(parseAppRelease("""{"versionName": "0.3.0"}"""))
|
||||
assertNull(parseAppRelease("""{"versionCode": "three", "versionName": "0.3.0"}"""))
|
||||
assertNull(parseAppRelease("""{"versionCode": 0, "versionName": "0.3.0"}"""))
|
||||
assertNull(parseAppRelease("""{"versionCode": 3}"""))
|
||||
assertNull(parseAppRelease("[]"))
|
||||
}
|
||||
|
||||
@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")
|
||||
viewModel.checkAtLaunch()
|
||||
advanceUntilIdle()
|
||||
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||
assertEquals(release, viewModel.state.value.prompt)
|
||||
viewModel.dismissPrompt()
|
||||
assertNull(viewModel.state.value.prompt)
|
||||
assertEquals(release, viewModel.state.value.available)
|
||||
}
|
||||
|
||||
@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")
|
||||
viewModel.checkAtLaunch()
|
||||
advanceUntilIdle()
|
||||
viewModel.dismissPrompt()
|
||||
viewModel.checkAtLaunch()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, calls)
|
||||
assertNull(viewModel.state.value.prompt)
|
||||
}
|
||||
|
||||
@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")
|
||||
viewModel.checkNow()
|
||||
advanceUntilIdle()
|
||||
assertEquals(UpdateCheck.UP_TO_DATE, viewModel.state.value.check)
|
||||
assertNull(viewModel.state.value.available)
|
||||
}
|
||||
|
||||
@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")
|
||||
viewModel.checkNow()
|
||||
advanceUntilIdle()
|
||||
assertEquals(UpdateCheck.AVAILABLE, viewModel.state.value.check)
|
||||
assertNull(viewModel.state.value.prompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable version file is a failed check`() = runTest(dispatcher) {
|
||||
val viewModel = AppUpdateViewModel({ null }, 2, "0.2.0")
|
||||
viewModel.checkNow()
|
||||
advanceUntilIdle()
|
||||
assertEquals(UpdateCheck.FAILED, viewModel.state.value.check)
|
||||
}
|
||||
}
|
||||
@ -1448,3 +1448,14 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
||||
- Checks and results: the required checks pass; 461 unit tests, 0 failures; lint 0 errors.
|
||||
- Live verification: none; no device was connected.
|
||||
- Next owner: the product owner, to confirm on the device.
|
||||
|
||||
## 2026-10-07 (continued) — TASK-066: download page and update check
|
||||
|
||||
- Request: the human product owner asked for a password-protected web download and for the app
|
||||
to offer the newest version.
|
||||
- What changed: see `tasks/066-download-page-and-update-check.md`. Version 0.2.0
|
||||
(versionCode 2) is published at `https://choimy0204.github.io/ubgitea/`.
|
||||
- Checks and results: the required checks pass; 468 unit tests, 0 failures; lint 0 errors.
|
||||
- Caution: the published APK is the debug build, signed with this PC's debug key. It installs
|
||||
over copies built on this PC; a copy built elsewhere has to be removed first.
|
||||
- Next owner: the product owner, to try the link and the install on the device.
|
||||
|
||||
47
tasks/066-download-page-and-update-check.md
Normal file
47
tasks/066-download-page-and-update-check.md
Normal file
@ -0,0 +1,47 @@
|
||||
# TASK-066: download page and in-app update check
|
||||
|
||||
Status: done
|
||||
Owner: Claude
|
||||
Reviewer: human product owner
|
||||
|
||||
## Request
|
||||
|
||||
The human product owner asked for a web link to download the app, protected by a download
|
||||
password they chose, and for the app to offer the newest version when it is not on it. They chose
|
||||
to host it in a folder of the existing `choimy0204.github.io` site.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `https://choimy0204.github.io/ubgitea/` serves a page that asks for the password and only then
|
||||
gives the APK. The APK on the host is encrypted (AES-256-GCM, key from PBKDF2-SHA256), so its
|
||||
address alone gives nothing. The password is not in this repository.
|
||||
- `version.json` beside it names the newest versionCode, versionName and notes.
|
||||
- The app reads it once at launch; when it is newer than the installed build, a dialog offers the
|
||||
download page. Settings shows the installed version, a check button, and the download page when
|
||||
something newer is out. No Gitea token is sent to that host.
|
||||
- `tools/publish-download.py` builds the page from an APK; versionCode is raised for every build
|
||||
published.
|
||||
|
||||
## Writable paths
|
||||
|
||||
`app/build.gradle.kts`, `app/src/main/java/com/ubisam/ubgitea/core/update/`, `MainActivity.kt`,
|
||||
`feature/settings/SettingsScreen.kt`, `res/values/strings_update.xml`, `app/src/test/.../core/update/`,
|
||||
`tools/publish-download.py`, `docs/development-log.md`, this file.
|
||||
|
||||
## Checks
|
||||
|
||||
```text
|
||||
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
- 2026-10-07: the required checks pass; 468 unit tests, 0 failures; lint 0 errors.
|
||||
- The page's decryption was run with Node's WebCrypto (the browser's API): the right password
|
||||
gives a file byte-identical to `app-debug.apk`, a wrong one is refused.
|
||||
- Published version 0.2.0 (versionCode 2), the debug build, to `choimy0204.github.io/ubgitea/`.
|
||||
- Not seen: the page opened on a phone, the install over an existing copy, the in-app dialog.
|
||||
|
||||
## Review findings
|
||||
|
||||
None yet.
|
||||
179
tools/publish-download.py
Normal file
179
tools/publish-download.py
Normal file
@ -0,0 +1,179 @@
|
||||
"""Builds the password-protected download page for one APK (TASK-066).
|
||||
|
||||
The APK is encrypted with AES-256-GCM under a key derived from the password with PBKDF2-SHA256,
|
||||
so the files can sit on a public static host: the page asks for the password and decrypts in the
|
||||
browser with WebCrypto. version.json beside it is plain; the app reads it to know a newer build
|
||||
is out.
|
||||
|
||||
python tools/publish-download.py <apk> <out-dir> --notes "what changed"
|
||||
|
||||
The password is read from UBGITEA_DOWNLOAD_PASSWORD, or asked for. It is never written anywhere.
|
||||
versionCode and versionName are read from app/build.gradle.kts.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import getpass
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
ITERATIONS = 310_000
|
||||
ENCRYPTED_NAME = "ubgitea.apk.enc"
|
||||
|
||||
|
||||
def gradle_version(root: Path):
|
||||
text = (root / "app" / "build.gradle.kts").read_text(encoding="utf-8")
|
||||
code = re.search(r"versionCode\s*=\s*(\d+)", text)
|
||||
name = re.search(r'versionName\s*=\s*"([^"]+)"', text)
|
||||
if not code or not name:
|
||||
sys.exit("versionCode / versionName not found in app/build.gradle.kts")
|
||||
return int(code.group(1)), name.group(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("apk", type=Path)
|
||||
parser.add_argument("out", type=Path)
|
||||
parser.add_argument("--notes", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
password = os.environ.get("UBGITEA_DOWNLOAD_PASSWORD") or getpass.getpass("Download password: ")
|
||||
if not password:
|
||||
sys.exit("A password is required.")
|
||||
code, name = gradle_version(Path(__file__).resolve().parent.parent)
|
||||
|
||||
data = args.apk.read_bytes()
|
||||
salt = os.urandom(16)
|
||||
iv = os.urandom(12)
|
||||
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, ITERATIONS, 32)
|
||||
sealed = AESGCM(key).encrypt(iv, data, None)
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
(args.out / ENCRYPTED_NAME).write_bytes(sealed)
|
||||
release = {"versionCode": code, "versionName": name, "notes": args.notes}
|
||||
(args.out / "version.json").write_text(json.dumps(release, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
meta = {
|
||||
"salt": base64.b64encode(salt).decode(),
|
||||
"iv": base64.b64encode(iv).decode(),
|
||||
"iterations": ITERATIONS,
|
||||
"file": ENCRYPTED_NAME,
|
||||
"fileName": f"UBGitea-{name}.apk",
|
||||
"size": len(data),
|
||||
}
|
||||
page = PAGE.replace("{{META}}", json.dumps(meta)) \
|
||||
.replace("{{VERSION}}", html.escape(name)) \
|
||||
.replace("{{NOTES}}", html.escape(args.notes)) \
|
||||
.replace("{{SIZE}}", f"{len(data) / 1024 / 1024:.1f} MB")
|
||||
(args.out / "index.html").write_text(page, encoding="utf-8")
|
||||
print(f"UBGitea {name} (versionCode {code}) -> {args.out}")
|
||||
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>UBGitea 다운로드</title>
|
||||
<style>
|
||||
:root { --bg: #f4f7fc; --card: #ffffff; --ink: #14213d; --muted: #5b6b86; --line: #d8e1f0;
|
||||
--blue: #1f5fd6; --blue-ink: #ffffff; --bad: #c62828; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #0d1524; --card: #16213a; --ink: #e8eefb; --muted: #9fb0cf; --line: #2a3a5c;
|
||||
--blue: #6d9cff; --blue-ink: #0d1524; --bad: #ff8a80; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
main { width: 100%; max-width: 420px; background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 20px; padding: 28px 24px; }
|
||||
h1 { margin: 0 0 4px; font-size: 24px; }
|
||||
.version { color: var(--blue); font-weight: 600; }
|
||||
p { color: var(--muted); line-height: 1.5; margin: 8px 0; }
|
||||
.notes { white-space: pre-line; color: var(--ink); background: var(--bg); border-radius: 12px;
|
||||
padding: 12px 14px; margin: 16px 0; }
|
||||
.notes:empty { display: none; }
|
||||
label { display: block; font-weight: 600; margin: 20px 0 8px; }
|
||||
input { width: 100%; font-size: 17px; padding: 14px; border-radius: 12px; border: 1px solid var(--line);
|
||||
background: var(--card); color: var(--ink); }
|
||||
button { width: 100%; margin-top: 12px; font-size: 17px; font-weight: 600; padding: 14px; border: 0;
|
||||
border-radius: 12px; background: var(--blue); color: var(--blue-ink); cursor: pointer; }
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
#status { min-height: 1.5em; margin-top: 12px; }
|
||||
#status.bad { color: var(--bad); }
|
||||
ol { color: var(--muted); padding-left: 20px; line-height: 1.6; margin: 20px 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>UBGitea</h1>
|
||||
<div class="version">버전 {{VERSION}} · {{SIZE}}</div>
|
||||
<div class="notes">{{NOTES}}</div>
|
||||
<form id="form">
|
||||
<label for="password">다운로드 비밀번호</label>
|
||||
<input id="password" type="password" autocomplete="off" required>
|
||||
<button id="go" type="submit">APK 다운로드</button>
|
||||
</form>
|
||||
<p id="status" role="status" aria-live="polite"></p>
|
||||
<ol>
|
||||
<li>받은 APK 파일을 엽니다.</li>
|
||||
<li>"출처를 알 수 없는 앱" 설치를 이 브라우저에 허용합니다.</li>
|
||||
<li>설치(업데이트)를 누릅니다. 로그인과 설정은 그대로 남습니다.</li>
|
||||
</ol>
|
||||
</main>
|
||||
<script>
|
||||
const META = {{META}};
|
||||
const status = document.getElementById("status");
|
||||
const go = document.getElementById("go");
|
||||
const b64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
|
||||
function say(text, bad) { status.textContent = text; status.className = bad ? "bad" : ""; }
|
||||
document.getElementById("form").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
go.disabled = true;
|
||||
try {
|
||||
say("파일을 받는 중…");
|
||||
const response = await fetch(META.file, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("fetch");
|
||||
const sealed = await response.arrayBuffer();
|
||||
say("여는 중…");
|
||||
const password = new TextEncoder().encode(document.getElementById("password").value);
|
||||
const base = await crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveKey"]);
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: b64(META.salt), iterations: META.iterations, hash: "SHA-256" },
|
||||
base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
|
||||
let apk;
|
||||
try {
|
||||
apk = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.iv) }, key, sealed);
|
||||
} catch (e) {
|
||||
say("비밀번호가 맞지 않습니다.", true);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([apk], { type: "application/vnd.android.package-archive" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = META.fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
say("다운로드를 시작했습니다. 받은 파일을 열어 설치하세요.");
|
||||
} catch (e) {
|
||||
say("파일을 받지 못했습니다. 잠시 뒤 다시 시도하세요.", true);
|
||||
} finally {
|
||||
go.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user