Skip to content

Commit e93c90f

Browse files
Improve v1.2.1 in-app update install
1 parent df1e080 commit e93c90f

6 files changed

Lines changed: 173 additions & 15 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ venv.bak/
3939

4040
# Application Data & Temporary Files
4141
.agent/skills/
42+
.codex/
4243
grades_raw.json
4344
shared_grades/
4445
*.json

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
- **重要規則**:將更新發布推送至 GitHub 前,必須先將 `CHANGELOG.md` 寫完並請使用者檢查和修改,確認無誤後才能推送。
3131
- **重要規則**`CHANGELOG.md` 僅用於記錄與 Android app 有關的更新。若僅修改展示素材、文件或 workflow,請勿新增版本號。
3232
- GitHub Secrets 需設定 `ANDROID_RELEASE_KEYSTORE_BASE64``ANDROID_RELEASE_KEYSTORE_PASSWORD``ANDROID_RELEASE_KEY_ALIAS``ANDROID_RELEASE_KEY_PASSWORD`。不要提交 keystore 或密碼。
33+
- App 內更新只能下載 APK 後呼叫系統安裝器;Android 不允許靜默安裝。下載的 APK 透過 `FileProvider` 暫存在 app cache 的 `updates/`,若系統要求,使用者需在安裝流程中允許此 App 安裝未知應用。
3334

3435
## Android UI fake data
3536

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Features
66
- 更新成績頁下拉重新整理的載入樣式,讓重新整理狀態更符合新版介面風格。
7+
- 改善內建更新流程,下載 APK 時會在 App 內顯示進度,完成後直接交給系統安裝器。
78

89
### Bug Fixes
910
- 改善 WebView 登入流程的除錯資訊,當登入頁面資料解析失敗時更容易定位問題。

android/app/src/main/AndroidManifest.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
66
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
77
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
8+
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
89

910
<application
1011
android:allowBackup="false"
@@ -61,6 +62,16 @@
6162
<action android:name="android.intent.action.BOOT_COMPLETED" />
6263
</intent-filter>
6364
</receiver>
65+
66+
<provider
67+
android:name="androidx.core.content.FileProvider"
68+
android:authorities="${applicationId}.fileprovider"
69+
android:exported="false"
70+
android:grantUriPermissions="true">
71+
<meta-data
72+
android:name="android.support.FILE_PROVIDER_PATHS"
73+
android:resource="@xml/file_paths" />
74+
</provider>
6475
</application>
6576

6677
</manifest>

android/app/src/main/java/com/clhs/score/ui/UpdateResultDialog.kt

Lines changed: 153 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,40 @@
11
package com.clhs.score.ui
22

3+
import android.content.Context
34
import android.content.Intent
45
import android.widget.Toast
56
import androidx.compose.foundation.layout.Arrangement
67
import androidx.compose.foundation.layout.Column
8+
import androidx.compose.foundation.layout.Row
9+
import androidx.compose.foundation.layout.size
710
import androidx.compose.material3.AlertDialog
11+
import androidx.compose.material3.CircularWavyProgressIndicator
12+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
813
import androidx.compose.material3.MaterialTheme
914
import androidx.compose.material3.Text
1015
import androidx.compose.material3.TextButton
1116
import androidx.compose.runtime.Composable
1217
import androidx.compose.runtime.LaunchedEffect
18+
import androidx.compose.runtime.getValue
19+
import androidx.compose.runtime.mutableStateOf
20+
import androidx.compose.runtime.remember
21+
import androidx.compose.runtime.rememberCoroutineScope
22+
import androidx.compose.runtime.setValue
23+
import androidx.compose.ui.Alignment
24+
import androidx.compose.ui.Modifier
1325
import androidx.compose.ui.platform.LocalContext
1426
import androidx.compose.ui.unit.dp
27+
import androidx.core.content.FileProvider
1528
import androidx.core.net.toUri
1629
import com.clhs.score.data.UpdateResult
30+
import java.io.File
31+
import kotlinx.coroutines.Dispatchers
32+
import kotlinx.coroutines.launch
33+
import kotlinx.coroutines.withContext
34+
import okhttp3.OkHttpClient
35+
import okhttp3.Request
1736

37+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
1838
@Composable
1939
fun UpdateResultDialog(
2040
result: UpdateResult?,
@@ -36,12 +56,35 @@ fun UpdateResultDialog(
3656
}
3757
}
3858
is UpdateResult.NewVersion -> {
59+
var isInstalling by remember(result.apkDownloadUrl) { mutableStateOf(false) }
60+
var downloadProgress by remember(result.apkDownloadUrl) { mutableStateOf<Float?>(null) }
61+
val scope = rememberCoroutineScope()
3962
AlertDialog(
40-
onDismissRequest = onDismiss,
63+
onDismissRequest = { if (!isInstalling) onDismiss() },
4164
title = { Text("有新版本") },
4265
text = {
4366
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
4467
Text("v${result.versionName} 已可更新")
68+
if (isInstalling) {
69+
val progress = downloadProgress
70+
Row(
71+
horizontalArrangement = Arrangement.spacedBy(12.dp),
72+
verticalAlignment = Alignment.CenterVertically,
73+
) {
74+
if (progress == null) {
75+
CircularWavyProgressIndicator(modifier = Modifier.size(28.dp))
76+
} else {
77+
CircularWavyProgressIndicator(
78+
progress = { progress },
79+
modifier = Modifier.size(28.dp),
80+
)
81+
}
82+
Text(
83+
text = progress?.let { "下載中 ${(it * 100).toInt()}%" } ?: "下載中...",
84+
style = MaterialTheme.typography.bodyMedium,
85+
)
86+
}
87+
}
4588
if (result.releaseNotes.isNotBlank()) {
4689
Text(
4790
text = result.releaseNotes.take(300),
@@ -52,26 +95,121 @@ fun UpdateResultDialog(
5295
}
5396
},
5497
confirmButton = {
55-
TextButton(onClick = {
56-
val url = result.apkDownloadUrl ?: result.htmlUrl
57-
try {
58-
val uri = url.toUri()
59-
if (uri.scheme !in listOf("http", "https")) {
60-
error("unsupported update URL")
98+
TextButton(
99+
enabled = !isInstalling,
100+
onClick = {
101+
val apkUrl = result.apkDownloadUrl
102+
if (apkUrl == null) {
103+
openUrl(context, result.htmlUrl)
104+
onDismiss()
105+
return@TextButton
61106
}
62-
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
63-
} catch (_: Exception) {
64-
Toast.makeText(context, "無法開啟連結", Toast.LENGTH_SHORT).show()
65-
}
66-
onDismiss()
67-
}) {
68-
Text(if (result.apkDownloadUrl != null) "下載 APK" else "前往 GitHub")
107+
scope.launch {
108+
isInstalling = true
109+
downloadProgress = 0f
110+
try {
111+
val apk = downloadUpdateApk(context.applicationContext, apkUrl) {
112+
downloadProgress = it
113+
}
114+
openApkInstaller(context, apk)
115+
onDismiss()
116+
} catch (_: Exception) {
117+
Toast.makeText(context, "下載或安裝失敗", Toast.LENGTH_LONG).show()
118+
} finally {
119+
isInstalling = false
120+
downloadProgress = null
121+
}
122+
}
123+
},
124+
) {
125+
Text(
126+
when {
127+
isInstalling -> "下載中..."
128+
result.apkDownloadUrl != null -> "安裝 APK"
129+
else -> "前往 GitHub"
130+
},
131+
)
69132
}
70133
},
71134
dismissButton = {
72-
TextButton(onClick = onDismiss) { Text("稍後") }
135+
TextButton(
136+
enabled = !isInstalling,
137+
onClick = onDismiss,
138+
) { Text("稍後") }
73139
},
74140
)
75141
}
76142
}
77143
}
144+
145+
private fun openUrl(context: Context, url: String) {
146+
try {
147+
val uri = url.toUri()
148+
if (uri.scheme !in listOf("http", "https")) error("unsupported update URL")
149+
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
150+
} catch (_: Exception) {
151+
Toast.makeText(context, "無法開啟連結", Toast.LENGTH_SHORT).show()
152+
}
153+
}
154+
155+
private suspend fun downloadUpdateApk(
156+
context: Context,
157+
url: String,
158+
onProgress: suspend (Float?) -> Unit,
159+
): File =
160+
withContext(Dispatchers.IO) {
161+
suspend fun reportProgress(value: Float?) {
162+
withContext(Dispatchers.Main.immediate) {
163+
onProgress(value)
164+
}
165+
}
166+
167+
val dir = File(context.cacheDir, "updates")
168+
dir.mkdirs()
169+
val apk = File(dir, "clhs-score-update.apk")
170+
apk.delete()
171+
172+
val request = Request.Builder().url(url).get().build()
173+
updateDownloadClient.newCall(request).execute().use { response ->
174+
if (!response.isSuccessful) error("HTTP ${response.code}")
175+
val totalBytes = response.body.contentLength()
176+
if (totalBytes <= 0) reportProgress(null)
177+
response.body.byteStream().use { input ->
178+
apk.outputStream().use { output ->
179+
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
180+
var copiedBytes = 0L
181+
var lastPercent = -1
182+
while (true) {
183+
val read = input.read(buffer)
184+
if (read < 0) break
185+
output.write(buffer, 0, read)
186+
copiedBytes += read
187+
if (totalBytes > 0) {
188+
val percent = ((copiedBytes * 100) / totalBytes).toInt()
189+
if (percent != lastPercent) {
190+
lastPercent = percent
191+
reportProgress(percent.coerceIn(0, 100) / 100f)
192+
}
193+
}
194+
}
195+
}
196+
}
197+
if (totalBytes > 0) reportProgress(1f)
198+
}
199+
apk
200+
}
201+
202+
private fun openApkInstaller(context: Context, apk: File) {
203+
val uri = FileProvider.getUriForFile(
204+
context,
205+
"${context.packageName}.fileprovider",
206+
apk,
207+
)
208+
context.startActivity(
209+
Intent(Intent.ACTION_VIEW)
210+
.setDataAndType(uri, "application/vnd.android.package-archive")
211+
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION),
212+
)
213+
}
214+
215+
private val updateDownloadClient = OkHttpClient()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<paths xmlns:android="http://schemas.android.com/apk/res/android">
3+
<cache-path
4+
name="update_apks"
5+
path="updates/" />
6+
</paths>

0 commit comments

Comments
 (0)