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:
최민영 2026-10-07 20:52:45 +09:00
parent 54530096e0
commit 69556b1e23
10 changed files with 586 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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