11package com.clhs.score.ui
22
3+ import android.content.Context
34import android.content.Intent
45import android.widget.Toast
56import androidx.compose.foundation.layout.Arrangement
67import androidx.compose.foundation.layout.Column
8+ import androidx.compose.foundation.layout.Row
9+ import androidx.compose.foundation.layout.size
710import androidx.compose.material3.AlertDialog
11+ import androidx.compose.material3.CircularWavyProgressIndicator
12+ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
813import androidx.compose.material3.MaterialTheme
914import androidx.compose.material3.Text
1015import androidx.compose.material3.TextButton
1116import androidx.compose.runtime.Composable
1217import 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
1325import androidx.compose.ui.platform.LocalContext
1426import androidx.compose.ui.unit.dp
27+ import androidx.core.content.FileProvider
1528import androidx.core.net.toUri
1629import 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
1939fun 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 ()
0 commit comments