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"
|
applicationId = "com.ubisam.ubgitea"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
// Raised for every build published to the download page (TASK-066), or the app will not
|
||||||
versionName = "0.1.0"
|
// see it as newer.
|
||||||
|
versionCode = 2
|
||||||
|
versionName = "0.2.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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.auth.protocol.UBGiteaOAuthDefaults
|
||||||
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
|
||||||
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 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.core.notifications.FavoritePushScheduler
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
@ -103,6 +109,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
|
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
|
||||||
CodeWorkbenchViewModel.Factory(application)
|
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
|
// 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
|
// 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 homeState by homeViewModel.state.collectAsState()
|
||||||
val workState by workViewModel.state.collectAsState()
|
val workState by workViewModel.state.collectAsState()
|
||||||
val workbenchState by workbenchViewModel.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) {
|
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
|
||||||
mutableStateOf(listOf<Route>(Route.Home))
|
mutableStateOf(listOf<Route>(Route.Home))
|
||||||
@ -465,6 +477,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
onSetThemeMode = settingsViewModel::setThemeMode,
|
onSetThemeMode = settingsViewModel::setThemeMode,
|
||||||
onLogout = ::signOut,
|
onLogout = ::signOut,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
|
update = updateState,
|
||||||
|
onCheckUpdate = updateViewModel::checkNow,
|
||||||
|
onOpenDownloadPage = ::openDownloadPage,
|
||||||
)
|
)
|
||||||
Route.Notifications -> NotificationsScreen(
|
Route.Notifications -> NotificationsScreen(
|
||||||
state = notificationsState,
|
state = notificationsState,
|
||||||
@ -559,6 +574,25 @@ class MainActivity : ComponentActivity() {
|
|||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
viewModel.browserRequests.collect(::openAuthorizationTab)
|
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)
|
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?) {
|
private fun consumeCallback(intent: Intent?) {
|
||||||
val callback = intent?.dataString ?: return
|
val callback = intent?.dataString ?: return
|
||||||
setIntent(Intent(intent).setData(null))
|
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.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -24,12 +25,17 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
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.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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||||
import com.ubisam.ubgitea.core.settings.ThemeMode
|
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.Avatar
|
||||||
import com.ubisam.ubgitea.ui.components.ListCard
|
import com.ubisam.ubgitea.ui.components.ListCard
|
||||||
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
import com.ubisam.ubgitea.ui.components.ScreenTopBar
|
||||||
@ -49,6 +55,9 @@ fun SettingsScreen(
|
|||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
update: AppUpdateUiState? = null,
|
||||||
|
onCheckUpdate: () -> Unit = {},
|
||||||
|
onOpenDownloadPage: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Column(modifier.fillMaxSize()) {
|
Column(modifier.fillMaxSize()) {
|
||||||
@ -152,11 +161,53 @@ fun SettingsScreen(
|
|||||||
onCheckedChange = onSetFavoritePushNotificationsEnabled,
|
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))
|
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
|
private val ThemeMode.label: Int
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
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.
|
- Checks and results: the required checks pass; 461 unit tests, 0 failures; lint 0 errors.
|
||||||
- Live verification: none; no device was connected.
|
- Live verification: none; no device was connected.
|
||||||
- Next owner: the product owner, to confirm on the device.
|
- 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