Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,42 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}

- name: Generate update manifest
run: |
APK_PATH=$(find app/build/outputs/apk/release -maxdepth 1 -name '*-release.apk' -print -quit)
test -n "$APK_PATH"
APK_NAME=$(basename "$APK_PATH")
APK_SIZE=$(stat -c%s "$APK_PATH")
TAG_NAME="${{ steps.version.outputs.tag_name }}"
RELEASE_URL="${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG_NAME"
DOWNLOAD_URL="${{ github.server_url }}/${{ github.repository }}/releases/download/$TAG_NAME/$APK_NAME"

jq -n \
--arg tag_name "$TAG_NAME" \
--arg release_name "$TAG_NAME" \
--arg published_at "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
--arg release_url "$RELEASE_URL" \
--arg asset_name "$APK_NAME" \
--arg download_url "$DOWNLOAD_URL" \
--argjson version_code "${{ steps.version.outputs.version_code }}" \
--argjson asset_size "$APK_SIZE" \
'{
schema_version: 1,
tag_name: $tag_name,
name: $release_name,
draft: false,
prerelease: false,
published_at: $published_at,
html_url: $release_url,
version_code: $version_code,
assets: [{
name: $asset_name,
size: $asset_size,
browser_download_url: $download_url
}]
}' > latest.json
jq empty latest.json

- name: Upload release APK
uses: actions/upload-artifact@v4
if: always()
Expand Down Expand Up @@ -202,7 +238,9 @@ jobs:
target_commitish: ${{ github.sha }}
name: v${{ steps.version.outputs.version_name }}
body_path: release-notes.md
files: app/build/outputs/apk/release/*-release.apk
files: |
app/build/outputs/apk/release/*-release.apk
latest.json
fail_on_unmatched_files: true
prerelease: false
draft: false
Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ plugins {

// 支持通过 -PVERSION_NAME / -PVERSION_CODE 显式传入版本信息。
// 未显式传入 VERSION_NAME 时,默认把最后一段 patch 替换为 BUILD_NUMBER。
val baseAppVersionName = "2.1.0"
val baseAppVersionName = "2.2.0"

fun String?.nonBlankOrNull(): String? =
this?.trim()?.takeIf { it.isNotBlank() }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.miruplay.tv.model

import kotlinx.serialization.Serializable
import java.net.URI

@Serializable
enum class CloudDriveLibraryMode {
Expand All @@ -10,6 +11,12 @@ enum class CloudDriveLibraryMode {

const val DEFAULT_CLOUD_DRIVE_ENDPOINT_URL = "http://localhost:19798"

fun String.isDefaultCloudDriveWebDavEndpoint(): Boolean =
runCatching {
URI(MediaPathConventions.canonicalizeRemoteUrl(this)).port ==
URI(DEFAULT_CLOUD_DRIVE_ENDPOINT_URL).port
}.getOrDefault(false)

@Serializable
data class CloudDriveAutomationConfig(
val endpointUrl: String = DEFAULT_CLOUD_DRIVE_ENDPOINT_URL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ fun settingsAppUpdateIdleStatus(): String =
"尚未检查更新。"

fun settingsAppUpdateCheckingStatus(): String =
"正在检查 GitHub Release。"
"正在读取更新清单。"

fun settingsAppUpdateReadyStatus(versionName: String): String =
"发现新版本 ${versionName.ifBlank { "未知版本" }}。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ class SettingsSectionDisplayConventionsTest {
assertEquals("下载安装", settingsAppUpdateInstallActionLabel())
assertEquals("安装授权", settingsAppUpdatePermissionActionLabel())
assertEquals("尚未检查更新。", settingsAppUpdateIdleStatus())
assertEquals("正在检查 GitHub Release。", settingsAppUpdateCheckingStatus())
assertEquals("正在读取更新清单。", settingsAppUpdateCheckingStatus())
assertEquals("发现新版本 2026.05.26。", settingsAppUpdateReadyStatus("2026.05.26"))
assertEquals("当前已是最新版本 2026.05.26。", settingsAppUpdateLatestStatus("2026.05.26"))
assertEquals("正在下载 APK 42%", settingsAppUpdateDownloadProgressStatus(42))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class AppUpdateRepositoryImpl internal constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val cloudDriveRepository: CloudDriveAutomationRepository,
private val updateManifestUrl: String,
private val latestReleaseApiUrl: String,
) : AppUpdateRepository {

Expand All @@ -55,6 +56,7 @@ class AppUpdateRepositoryImpl internal constructor(
context = context,
okHttpClient = okHttpClient,
cloudDriveRepository = cloudDriveRepository,
updateManifestUrl = UPDATE_MANIFEST_URL,
latestReleaseApiUrl = LATEST_RELEASE_API_URL,
)

Expand All @@ -63,72 +65,96 @@ class AppUpdateRepositoryImpl internal constructor(
val currentVersionCode = currentVersionCode()
MiruLog.i(
TAG,
"Checking GitHub app update",
"Checking app update",
mapOf(
"current_version_name" to currentVersionName,
"current_version_code" to currentVersionCode.toString(),
"release_api_url" to latestReleaseApiUrl,
"update_manifest_url" to updateManifestUrl,
)
)

val latestWithSource = when (val manifest = fetchLatestUpdate(updateManifestUrl, githubApi = false)) {
is Result.Success -> manifest.data to "manifest"
is Result.Error -> {
MiruLog.w(
TAG,
"Update manifest unavailable; falling back to GitHub API",
attributes = mapOf("manifest_error" to manifest.error.toString()),
)
when (val fallback = fetchLatestUpdate(latestReleaseApiUrl, githubApi = true)) {
is Result.Success -> fallback.data to "github_api"
is Result.Error -> return@withContext Result.failure(fallback.error)
}
}
}
val latest = latestWithSource.first
val updateAvailable = GitHubAppUpdateMapper.isNewerThanCurrent(
latest = latest,
currentVersionName = currentVersionName,
currentVersionCode = currentVersionCode,
)
MiruLog.i(
TAG,
"App update check completed",
mapOf(
"source" to latestWithSource.second,
"latest_version_name" to latest.versionName,
"latest_version_code" to latest.versionCode.orEmptyString(),
"asset_name" to latest.assetName,
"asset_size_bytes" to latest.assetSizeBytes.toString(),
"update_available" to updateAvailable.toString(),
)
)
Result.success(
AppUpdateCheck(
currentVersionName = currentVersionName,
currentVersionCode = currentVersionCode,
latest = latest,
updateAvailable = updateAvailable,
)
)
}

private suspend fun fetchLatestUpdate(url: String, githubApi: Boolean): Result<AppUpdateInfo> {
val request = Request.Builder()
.url(latestReleaseApiUrl)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "MiruPlay/$currentVersionName")
.url(url)
.header("Accept", if (githubApi) "application/vnd.github+json" else "application/json")
.header("User-Agent", "MiruPlay/${currentVersionName()}")
.apply {
if (githubApi) {
header("X-GitHub-Api-Version", "2022-11-28")
} else {
header("Cache-Control", "no-cache")
}
}
.build()

try {
githubApiClient().newCall(request).execute().use { response ->
return try {
githubClient().newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val failureDetail = response.failureDetail()
MiruLog.w(
TAG,
"GitHub app update check failed",
"App update request failed",
attributes = mapOf(
"url" to url,
"http_code" to response.code.toString(),
"http_message" to response.message,
"http_failure_detail" to failureDetail,
)
)
return@withContext Result.failure(
AppError.NetworkError.HttpError(response.code, failureDetail)
)
return@use Result.failure(AppError.NetworkError.HttpError(response.code, failureDetail))
}
val responseBody = response.body?.string().orEmpty()
if (responseBody.isBlank()) {
return@withContext Result.failure(AppError.AppUpdateError.NoReleaseFound)
return@use Result.failure(AppError.AppUpdateError.NoReleaseFound)
}
val latest = GitHubAppUpdateMapper.parseLatestRelease(responseBody, json)
?: return@withContext Result.failure(AppError.AppUpdateError.NoInstallableApk)
val updateAvailable = GitHubAppUpdateMapper.isNewerThanCurrent(
latest = latest,
currentVersionName = currentVersionName,
currentVersionCode = currentVersionCode,
)
MiruLog.i(
TAG,
"GitHub app update check completed",
mapOf(
"latest_version_name" to latest.versionName,
"latest_version_code" to latest.versionCode.orEmptyString(),
"asset_name" to latest.assetName,
"asset_size_bytes" to latest.assetSizeBytes.toString(),
"update_available" to updateAvailable.toString(),
)
)
Result.success(
AppUpdateCheck(
currentVersionName = currentVersionName,
currentVersionCode = currentVersionCode,
latest = latest,
updateAvailable = updateAvailable,
)
)
GitHubAppUpdateMapper.parseLatestRelease(responseBody, json)
?.let { Result.success(it) }
?: Result.failure(AppError.AppUpdateError.NoInstallableApk)
}
} catch (error: Exception) {
MiruLog.w(TAG, "GitHub app update check threw", error)
Result.failure(AppError.NetworkError.ServerUnreachable(latestReleaseApiUrl))
MiruLog.w(TAG, "App update request threw", error, mapOf("url" to url))
Result.failure(AppError.NetworkError.ServerUnreachable(url))
}
}

Expand Down Expand Up @@ -284,7 +310,7 @@ class AppUpdateRepositoryImpl internal constructor(
return target
}

private suspend fun githubApiClient(): OkHttpClient =
private suspend fun githubClient(): OkHttpClient =
okHttpClient.newBuilder()
.proxy(currentProxy())
.build()
Expand Down Expand Up @@ -332,7 +358,10 @@ class AppUpdateRepositoryImpl internal constructor(

companion object {
private const val TAG = "AppUpdateRepository"
private const val LATEST_RELEASE_API_URL = "https://api.github.com/repos/ModerRAS/MiruPlay/releases/latest"
private const val UPDATE_MANIFEST_URL =
"https://github.com/ModerRAS/MiruPlay/releases/latest/download/latest.json"
private const val LATEST_RELEASE_API_URL =
"https://api.github.com/repos/ModerRAS/MiruPlay/releases/latest"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val APK_DOWNLOAD_CONNECT_TIMEOUT_SECONDS = 120L
private const val APK_DOWNLOAD_READ_TIMEOUT_SECONDS = 120L
Expand Down Expand Up @@ -369,7 +398,8 @@ internal object GitHubAppUpdateMapper {
val versionName = normalizeReleaseVersionName(tagName.ifBlank { releaseName })
return AppUpdateInfo(
versionName = versionName,
versionCode = versionCodeFromName(versionName),
versionCode = root["version_code"]?.jsonPrimitive?.longOrNull
?: versionCodeFromName(versionName),
releaseName = releaseName,
tagName = tagName,
publishedAt = publishedAt,
Expand Down
Loading
Loading