diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b40ff14..976e52a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index f177a2f..8082ad1 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -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.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)) diff --git a/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt new file mode 100644 index 0000000..4cacbb5 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdate.kt @@ -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() +} diff --git a/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt new file mode 100644 index 0000000..a00178f --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/core/update/AppUpdateViewModel.kt @@ -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 = 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 create(modelClass: Class): T { + require(modelClass.isAssignableFrom(AppUpdateViewModel::class.java)) + return AppUpdateViewModel(HttpAppReleaseSource(), BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) as T + } + } +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt index ca16d89..72382af 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/settings/SettingsScreen.kt @@ -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 diff --git a/app/src/main/res/values/strings_update.xml b/app/src/main/res/values/strings_update.xml new file mode 100644 index 0000000..db47c3a --- /dev/null +++ b/app/src/main/res/values/strings_update.xml @@ -0,0 +1,15 @@ + + + 앱 정보 + 현재 버전 %1$s + 업데이트 확인 + 확인하는 중… + 최신 버전입니다. + 새 버전을 확인하지 못했습니다. 네트워크를 확인하세요. + 새 버전 %1$s을(를) 받을 수 있습니다. + 다운로드 페이지 열기 + 새 버전이 있습니다 + 현재 %1$s → 새 버전 %2$s\n다운로드 페이지에서 비밀번호를 입력해 받은 뒤 설치하세요. + 다운로드 + 나중에 + diff --git a/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt b/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt new file mode 100644 index 0000000..61573bb --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/core/update/AppUpdateTest.kt @@ -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("404")) + 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) + } +} diff --git a/docs/development-log.md b/docs/development-log.md index 4302204..00450a6 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. diff --git a/tasks/066-download-page-and-update-check.md b/tasks/066-download-page-and-update-check.md new file mode 100644 index 0000000..f0a95f8 --- /dev/null +++ b/tasks/066-download-page-and-update-check.md @@ -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. diff --git a/tools/publish-download.py b/tools/publish-download.py new file mode 100644 index 0000000..6a9ff40 --- /dev/null +++ b/tools/publish-download.py @@ -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 --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 = """ + + + + + +UBGitea 다운로드 + + + +
+

UBGitea

+
버전 {{VERSION}} · {{SIZE}}
+
{{NOTES}}
+
+ + + +
+

+
    +
  1. 받은 APK 파일을 엽니다.
  2. +
  3. "출처를 알 수 없는 앱" 설치를 이 브라우저에 허용합니다.
  4. +
  5. 설치(업데이트)를 누릅니다. 로그인과 설정은 그대로 남습니다.
  6. +
+
+ + + +""" + +if __name__ == "__main__": + main()