From b7165cdaf85cb7e73abb4fb2dff8c5bec3ea43ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Thu, 12 Nov 2026 14:16:09 +0700 Subject: [PATCH 1/3] fix(updater): recover downloads and await native installer handoff --- .github/workflows/build-android.yml | 5 + .github/workflows/ci.yml | 2 +- .gitignore | 1 + README.md | 2 +- docs/testing/auto-update-recovery.md | 165 ++++++++ .../android/build.gradle | 7 +- .../android/src/main/AndroidManifest.xml | 1 + .../backgroundsync/UthBackgroundSyncPlugin.kt | 36 ++ .../update/ApkUpdateDownloadManager.kt | 353 ++++++++++++++++++ .../update/ApkUpdateInstaller.kt | 252 ++++++++++--- .../update/ApkUpdateDownloadManagerTest.kt | 133 +++++++ .../update/ApkUpdateInstallerFlowTest.kt | 161 ++++++++ .../update/ApkUpdateInstallerTest.kt | 104 ++++++ .../background_sync.py | 70 ++++ .../tests/test_package_contract.py | 75 +++- pyproject.toml | 2 +- src/core/update_checker.py | 170 ++++++++- src/core/update_coordinator.py | 145 +++++-- src/core/update_models.py | 1 + src/gui/app_controller.py | 311 +++++++-------- src/gui/components/settings/system_section.py | 8 +- src/gui/components/settings_view.py | 87 ++++- src/platform_utils/android_updates.py | 203 ++++++++++ src/platform_utils/background_sync.py | 16 + tests/test_android_updates.py | 209 +++++++++++ tests/test_background_sync_bridge.py | 26 ++ tests/test_gui_app_controller.py | 86 ++++- tests/test_release_metadata.py | 6 +- tests/test_settings_update_ui.py | 64 ++++ tests/test_update_checker.py | 308 +++++++++++++++ tests/test_update_coordinator.py | 168 ++++++++- 31 files changed, 2920 insertions(+), 257 deletions(-) create mode 100644 docs/testing/auto-update-recovery.md create mode 100644 extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManager.kt create mode 100644 extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManagerTest.kt create mode 100644 extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt create mode 100644 src/platform_utils/android_updates.py create mode 100644 tests/test_android_updates.py diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index f8e31f9..f5c90c3 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -85,6 +85,11 @@ jobs: python scripts/generate_public_runtime_config.py --sentry-dsn "$SENTRY_DSN" --output src/assets/diagnostics-config.json flet build apk --build-version "$PROJECT_VERSION" --build-number "$BUILD_NUMBER" --yes --verbose + - name: Test native update and background sync contracts + timeout-minutes: 15 + working-directory: build/flutter/android + run: ./gradlew :flet_uth_background_sync:testDebugUnitTest --no-daemon + - name: 🔎 Verify notification manifest wiring run: | mapfile -t APK_CANDIDATES < <(find build/apk -maxdepth 1 -name '*.apk' -type f | sort) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6cbdc0..4cb1a94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,7 @@ jobs: - name: Run tests run: | cd src - python -m pytest ../tests/ -q \ + python -m pytest ../tests/ ../extensions/flet_uth_background_sync/tests/ -q \ --tb=short \ --cov=. \ --cov-report=term-missing \ diff --git a/.gitignore b/.gitignore index 4c93f2a..aaee74a 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ flet_build/ # ── Android SDK/Gradle ── .gradle/ +.kotlin/ local.properties *.hprof diff --git a/README.md b/README.md index 33387fe..2b15587 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ UTHelper là dự án độc lập dành cho sinh viên. Dự án không đại Đại học Giao thông vận tải Thành phố Hồ Chí Minh và không thay thế các cổng thông tin chính thức của trường. -Phiên bản mã nguồn hiện tại là `2.5.0`. UI, runtime package và số build nền tảng +Phiên bản mã nguồn hiện tại là `2.5.1`. UI, runtime package và số build nền tảng đều được sinh từ version canonical trong `pyproject.toml`; không chỉnh version riêng trong từng nền tảng. diff --git a/docs/testing/auto-update-recovery.md b/docs/testing/auto-update-recovery.md new file mode 100644 index 0000000..f3aa410 --- /dev/null +++ b/docs/testing/auto-update-recovery.md @@ -0,0 +1,165 @@ +# Auto update recovery: issues #80 and #81 + +## Baseline and scope + +The affected release is `v2.5.0`, commit +`598a1fd3f1799bca3d04a6fd1a6b3f66ed9e3966`. The working tree was clean before +creating `hotfix/2.5.1-auto-update` from `main`. Local validation uses Windows, +Python 3.13.12, Flet 0.86.5, pytest 9.0.2 and Ruff 0.15.6. +The initial focused updater suite passed 61 tests before changes. + +This patch addresses the updater portion of #80 and avoids unnecessary waits +and repeat transfers discussed in #81. It does not resolve #80's notification +or tray issues, or #81's installer destination/payload optimization work. + +## Confirmed causes + +- The downloader imposed a shared 180-second transfer budget. Retries after + expiry could not make useful progress. A clean EOF before the manifest size + was treated as corrupt data and the otherwise reusable partial was removed. +- Completed packages were downloaded again after restarting the application; + only partial-transfer reuse existed. A same-candidate metadata recheck also + discarded the coordinator's reference to its already verified package. +- Android downloaded on the Python process's daemon worker. Closing the app + cancelled that owned operation; it had no independent OS download to recover. +- Android's launcher acknowledged success when it queued the bridge coroutine, + before the native result. `INSTALL_LAUNCHED` and the UI callback could disagree + or arrive in the opposite order. Native failures did not retain a useful + validation/permission/intent failure category. +- Download completion immediately opened a confirmation dialog, interrupting + other work. The banner combined download and verification, and settings did + not expose progress or recovery actions. The banner's progress bar had zero + width. The existing modal confirmation also blocked Escape dismissal. + +These defects are supported by source behavior and regression tests. They do +not establish which native exception caused the originally reported Android +installer failure: no affected-device logcat or installed signer was available. +The existing published APK manifest already declared installation permission; +adding the permission to the extension manifest also makes its own dependency +contract explicit, rather than claiming a missing final-release permission. + +## Resulting behavior + +The desktop downloader has a finite 600-second budget, deadline-aware retries, +and remaining-time socket limits. A valid partial resumes with Range; a server +that ignores Range safely restarts the file. A complete cache entry is used only +after size/SHA-256 verification. Platform identity/signature checks still run +before readiness and again before installation. + +Android now uses DownloadManager for the actual transfer. The native service +persists the OS request ID for the exact URL, hash, size and expected Android +identity. Recreating the service can find the same request and file. Checking +for updates only observes a previously user-authorized job; it does not start +an unsolicited transfer. Once candidate discovery runs, that job can be +reattached and verified. When automatic checks are disabled, use the manual +check to discover and reconnect to the pending update. + +Closing the app detaches its observer while leaving the OS job intact. Explicit +cancel removes the matching OS job and file. The observer has a finite +30-minute wait; reaching it leaves the job recoverable on retry. Android may +pause a download for network, storage or OS policy. This does not promise to +override Force stop, unavailable storage or device-specific background policy. + +The transfer notification is visible only during download, so a completion +notification does not provide an unverified APK installation shortcut. The app +offers installation after size/hash validation; native handoff additionally +checks package ID, expected newer version, manifest signer and installed signer. +The staged APK is retained and reverified for permission/installer retries. +Only `installer_opened` acknowledges handoff; opening permission settings does +not. Neither status means the user finished installing the update. + +Download progress, verification and installation have separate states. Users can +continue using the app or settings, cancel a transfer, and open confirmation +from the ready action when convenient. Dismissing confirmation retains the +package. Escape dismisses the dialog and restores focus to the initiating action. +Windows exits only after the existing native launcher acknowledges handoff. + +## Provider-live measurement, 12 November 2026 + +One fresh download on the Windows host used the published +`UTHelper-Setup-2.5.0.exe`, 48,817,112 bytes, SHA-256 +`adf83d750656ada1b61c782326f4f27e7ffdea05cbd41722531573428afb217d`. +It was verified without launching or installing it. + +| Phase | Seconds | +|---|---:| +| GitHub discovery | 1.474 | +| Fresh download and SHA-256 | 22.219 | +| Windows signer/timestamp/product identity verification | 1.315 | +| Second use, rehashing the cached file | 0.084 | + +The second use forbade network calls and still succeeded. The fresh run emitted +173 progress events. This demonstrates working provider discovery, verified +transfer and zero-network reuse. It is one network sample, not a bandwidth +improvement benchmark, installation measurement or a download-plus-setup SLA. +It cannot be compared as a controlled speedup against #81's earlier network +samples. Raw measurements and package files remain outside version control. + +## Validation and remaining acceptance + +Source tests cover successful discovery/download/verification/confirmation, +truncation and Range recovery, complete cache reuse and tampering, cancellation, +deadline expiry, same-candidate rechecks, permission retry, true native +acknowledgement and observer detachment. The normal full-suite command is: + +```powershell +$env:PYTHONPATH='src;extensions/flet_uth_background_sync/src;.' +.venv\Scripts\python.exe -m pytest tests extensions/flet_uth_background_sync/tests -q --tb=short +.venv\Scripts\ruff.exe check src tests scripts +``` + +The full local Python suite passed **1,670 tests**, skipping 25, and Ruff passed. +The native extension's production debug/release Kotlin compiled with SDK 36; +an initial SDK-35 attempt exposed that the existing AndroidX Core 1.17.0 +dependency requires SDK 36. The extension now compiles against 36, without +changing minimum/target SDK. + +Robolectric regression tests simulate DownloadManager rows/files and native +permission/intent handoff; they do not download from the real Android provider +or parse/install a signed APK. The initial local test task was blocked before +test execution by the local JDK's `jlink` missing `jdk.internal.vm.ci`; a stock +JDK retry and the new Android CI task provide separate verification. Do not +count successful production Kotlin compilation as a passing native test suite. + +A local Flet browser harness rendered the real changed update controls with +synthetic release data, at desktop and 360-pixel width, using light/dark themes. +Keyboard text entry continued during download. Readiness did not open a modal; +the explicit install action opened confirmation, and Escape dismissed it with +focus restored. This is scoped browser UI evidence, not Android lifecycle or +Windows installation acceptance. No customer content was rendered or captured. + +Required device acceptance before release remains: + +1. Android: begin a real signed upgrade, background/lock the device, reclaim + the UI process and reopen; confirm the same OS request continues/reconnects. +2. Grant unknown-source permission, return and retry without a network transfer; + observe the real system installer and complete the upgrade. Repeat cancellation + and wrong hash/package/version/signer cases without weakening checks. +3. Windows isolated profile/VM: upgrade signed EXE and MSI, exercise UAC denial, + installer cancellation and failure, and confirm app/data/location preservation. +4. Measure actual download, verification, setup and usable launch separately for + #81's time target. This patch does not claim a universal sub-30-second install. + +The patch does not publish a release or change user data/schema. Roll back the +topic changes through a reviewed PR if needed; do not change release credentials +or production branches to work around an unverified device failure. + +The single authored project version is prepared as `2.5.1` (Android build number +`2005001`), which is newer than the affected `2.5.0`/`2005000`. Runtime version +assets and platform build versions continue to be generated by the existing +release pipeline; no tag or signed release is created by these source changes. + +The installed `v2.5.0` still contains its original updater until it is upgraded. +Before publishing the patch, test discovery and handoff from that installed +binary; supplying an older version string to this source tree is not that test. +If the old updater cannot open the installer, offer the patch's official signed +package from [GitHub Releases](https://github.com/Chouwzi/UTHelper/releases) for a +manual in-place upgrade with the same signer. Do not ask users to uninstall or +reset their data to bootstrap the fix. + +## Platform references + +- [Android DownloadManager](https://developer.android.com/reference/android/app/DownloadManager) +- [DownloadManager request notification behavior](https://developer.android.com/reference/android/app/DownloadManager.Request) +- [Unknown-source installation access](https://developer.android.com/reference/android/content/pm/PackageManager#canRequestPackageInstalls()) +- [Flet asynchronous applications](https://docs.flet.dev/cookbook/async-apps/) diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/build.gradle b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/build.gradle index 9d76b3b..b795d4e 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/build.gradle +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/build.gradle @@ -19,7 +19,7 @@ apply plugin: 'kotlin-kapt' android { namespace 'com.uthelper.backgroundsync' - compileSdk 35 + compileSdk 36 defaultConfig { minSdk 23 @@ -33,6 +33,10 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } + + testOptions { + unitTests.includeAndroidResources = true + } } dependencies { @@ -45,4 +49,5 @@ dependencies { implementation 'androidx.room:room-ktx:2.8.4' kapt 'androidx.room:room-compiler:2.8.4' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' } diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/AndroidManifest.xml b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/AndroidManifest.xml index c146624..dbdb290 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/AndroidManifest.xml +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/UthBackgroundSyncPlugin.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/UthBackgroundSyncPlugin.kt index 7cb6ee1..ad96cce 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/UthBackgroundSyncPlugin.kt +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/UthBackgroundSyncPlugin.kt @@ -15,7 +15,9 @@ import com.uthelper.backgroundsync.data.UthDatabase import com.uthelper.backgroundsync.notification.AlarmScheduler import com.uthelper.backgroundsync.notification.NotificationReconciler import com.uthelper.backgroundsync.security.CredentialVault +import com.uthelper.backgroundsync.update.ApkUpdateDownloadManager import com.uthelper.backgroundsync.update.ApkUpdateInstaller +import com.uthelper.backgroundsync.update.UpdateDownloadIdentity import com.uthelper.backgroundsync.worker.BackgroundSyncScheduler import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall @@ -33,6 +35,7 @@ class UthBackgroundSyncPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val mainHandler = Handler(Looper.getMainLooper()) private val updateInstaller by lazy { ApkUpdateInstaller(context) } + private val updateDownloader by lazy { ApkUpdateDownloadManager(context) } override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { context = binding.applicationContext @@ -180,10 +183,22 @@ class UthBackgroundSyncPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { ) mapOf( "status" to update.status, + "stage" to update.stage, "bytes" to update.bytes, "sha256" to update.sha256, + "reason" to update.reason, ) } + "start_update_download" -> execute(result) { + updateDownloader.start(updateIdentity(call)).toMap() + } + "get_update_download_status" -> execute(result) { + updateDownloader.status(updateIdentity(call)).toMap() + } + "cancel_update_download" -> execute(result) { + updateDownloader.cancel(updateIdentity(call)) + null + } "cancel_update" -> { updateInstaller.cancel() result.success(null) @@ -205,6 +220,27 @@ class UthBackgroundSyncPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { } } + private fun updateIdentity(call: MethodCall): UpdateDownloadIdentity = + UpdateDownloadIdentity( + url = call.argument("url").orEmpty(), + sha256 = call.argument("sha256").orEmpty(), + expectedSize = call.argument("expected_size")?.toLong() ?: 0L, + expectedPackageId = call.argument("expected_package_id").orEmpty(), + expectedVersionCode = call.argument("expected_version_code")?.toLong() ?: 0L, + expectedCertificateSha256 = + call.argument("expected_certificate_sha256").orEmpty(), + ) + + private fun com.uthelper.backgroundsync.update.UpdateDownloadResult.toMap(): Map = + mapOf( + "status" to status, + "download_id" to downloadId, + "downloaded" to downloaded, + "total" to total, + "local_path" to localPath, + "reason" to reason, + ) + private fun execute( result: MethodChannel.Result, operation: suspend () -> Any?, diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManager.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManager.kt new file mode 100644 index 0000000..71887a4 --- /dev/null +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManager.kt @@ -0,0 +1,353 @@ +package com.uthelper.backgroundsync.update + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import android.os.Environment +import java.io.File +import java.net.URI +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +/** Release metadata that pins both the downloaded bytes and their Android identity. */ +data class UpdateDownloadIdentity( + val url: String, + val sha256: String, + val expectedSize: Long, + val expectedPackageId: String, + val expectedVersionCode: Long, + val expectedCertificateSha256: String, +) { + fun validate(installedPackageId: String) { + require(ApkUpdateDownloadManager.isApprovedReleaseUrl(url)) { + "Update URL is not an approved GitHub APK release" + } + require(sha256.matches(Regex("[0-9a-fA-F]{64}"))) { + "A release SHA-256 checksum is required" + } + require(expectedSize in 1..MAX_UPDATE_SIZE_BYTES) { "Release size is invalid" } + require(expectedPackageId == installedPackageId) { "Unexpected package identity" } + require(expectedVersionCode > 0L) { "A positive versionCode is required" } + normalizedFingerprint(expectedCertificateSha256) + } + + val key: String + get() = sha256Text( + listOf( + url, + sha256.lowercase(), + expectedSize.toString(), + expectedPackageId, + expectedVersionCode.toString(), + normalizedFingerprint(expectedCertificateSha256), + ).joinToString("\n").toByteArray() + ) +} + +data class UpdateDownloadResult( + val status: String, + val downloadId: Long? = null, + val downloaded: Long = 0L, + val total: Long = 0L, + val localPath: String? = null, + val reason: String? = null, +) + +/** + * Owns durable APK downloads through Android's system DownloadManager. The request id is + * persisted by the app, and the app-specific destination encodes the full release identity + * so an enqueue interrupted before that preference write can be recovered without a duplicate. + */ +class ApkUpdateDownloadManager(private val context: Context) { + private val preferences = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val verifiedFiles = ConcurrentHashMap>() + + fun start(identity: UpdateDownloadIdentity): UpdateDownloadResult { + identity.validate(context.packageName) + val manager = downloadManager() + val destination = destinationFor(identity) + + val existingId = readDownloadId(identity) ?: findRequestId(manager, identity) + if (existingId != null) { + writeDownloadId(identity, existingId) + val current = query(manager, identity, existingId) + if (current.status in ACTIVE_OR_COMPLETE_STATUSES) { + return current + } + manager.remove(existingId) + } + + destination.delete() + val request = DownloadManager.Request(Uri.parse(identity.url)).apply { + setTitle("UTHelper update") + setDescription("Đang tải bản cập nhật") + setMimeType(APK_MIME_TYPE) + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + setDestinationInExternalFilesDir( + context, + Environment.DIRECTORY_DOWNLOADS, + "update-${identity.key}.apk", + ) + } + val id = manager.enqueue(request) + check(writeDownloadId(identity, id)) { "Could not persist Android download identity" } + return query(manager, identity, id) + } + + fun status(identity: UpdateDownloadIdentity): UpdateDownloadResult { + identity.validate(context.packageName) + val manager = downloadManager() + val id = readDownloadId(identity) ?: findRequestId(manager, identity) + ?: return UpdateDownloadResult("missing", total = identity.expectedSize) + writeDownloadId(identity, id) + return query(manager, identity, id) + } + + fun cancel(identity: UpdateDownloadIdentity) { + identity.validate(context.packageName) + val manager = downloadManager() + val id = readDownloadId(identity) ?: findRequestId(manager, identity) + if (id != null) manager.remove(id) + destinationFor(identity).delete() + verifiedFiles.remove(identity.key) + preferences.edit().remove(downloadIdPreferenceKey(identity)).commit() + } + + private fun query( + manager: DownloadManager, + identity: UpdateDownloadIdentity, + id: Long, + ): UpdateDownloadResult { + manager.query(DownloadManager.Query().setFilterById(id)).use { cursor -> + if (!cursor.moveToFirst()) { + preferences.edit().remove(downloadIdPreferenceKey(identity)).commit() + verifiedFiles.remove(identity.key) + return UpdateDownloadResult("missing", total = identity.expectedSize) + } + + val requestUrl = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI)) + if (requestUrl != identity.url) { + manager.remove(id) + preferences.edit().remove(downloadIdPreferenceKey(identity)).commit() + verifiedFiles.remove(identity.key) + return UpdateDownloadResult( + "failed", + downloadId = id, + total = identity.expectedSize, + reason = "identity_mismatch", + ) + } + + val downloaded = cursor.getLong( + cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + ).coerceAtLeast(0L) + val total = identity.expectedSize + return when (cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))) { + DownloadManager.STATUS_PENDING -> + UpdateDownloadResult("pending", id, downloaded, total) + DownloadManager.STATUS_RUNNING -> + UpdateDownloadResult("running", id, downloaded, total) + DownloadManager.STATUS_PAUSED -> + UpdateDownloadResult("paused", id, downloaded, total) + DownloadManager.STATUS_SUCCESSFUL -> verifyCompletedDownload( + manager, + identity, + id, + downloaded, + ) + DownloadManager.STATUS_FAILED -> UpdateDownloadResult( + status = "failed", + downloadId = id, + downloaded = downloaded, + total = total, + reason = failureReason( + cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)) + ), + ) + else -> UpdateDownloadResult( + status = "failed", + downloadId = id, + downloaded = downloaded, + total = total, + reason = "unknown_download_status", + ) + } + } + } + + private fun verifyCompletedDownload( + manager: DownloadManager, + identity: UpdateDownloadIdentity, + id: Long, + downloaded: Long, + ): UpdateDownloadResult { + val file = destinationFor(identity) + if (!file.isFile || file.length() != identity.expectedSize) { + manager.remove(id) + file.delete() + verifiedFiles.remove(identity.key) + preferences.edit().remove(downloadIdPreferenceKey(identity)).commit() + return UpdateDownloadResult( + "failed", + id, + downloaded, + identity.expectedSize, + reason = "download_size_mismatch", + ) + } + + val fileStamp = file.length() to file.lastModified() + if (verifiedFiles[identity.key] != fileStamp) { + val actualSha256 = try { + sha256(file) + } catch (_: Exception) { + manager.remove(id) + file.delete() + verifiedFiles.remove(identity.key) + preferences.edit().remove(downloadIdPreferenceKey(identity)).commit() + return UpdateDownloadResult( + "failed", + id, + downloaded, + identity.expectedSize, + reason = "download_read_failed", + ) + } + if (!actualSha256.equals(identity.sha256, ignoreCase = true)) { + manager.remove(id) + file.delete() + verifiedFiles.remove(identity.key) + preferences.edit().remove(downloadIdPreferenceKey(identity)).commit() + return UpdateDownloadResult( + "failed", + id, + downloaded, + identity.expectedSize, + reason = "download_sha256_mismatch", + ) + } + verifiedFiles[identity.key] = fileStamp + } + + return UpdateDownloadResult( + status = "completed", + downloadId = id, + downloaded = identity.expectedSize, + total = identity.expectedSize, + localPath = file.absolutePath, + ) + } + + private fun findRequestId( + manager: DownloadManager, + identity: UpdateDownloadIdentity, + ): Long? { + val expectedLocalUri = Uri.fromFile(destinationFor(identity)).toString() + manager.query(DownloadManager.Query()).use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID) + val localUriColumn = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI) + val uriColumn = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI) + while (cursor.moveToNext()) { + if ( + cursor.getString(uriColumn) == identity.url && + cursor.getString(localUriColumn) == expectedLocalUri + ) { + return cursor.getLong(idColumn) + } + } + } + return null + } + + private fun readDownloadId(identity: UpdateDownloadIdentity): Long? { + val value = preferences.getLong(downloadIdPreferenceKey(identity), NO_DOWNLOAD_ID) + return value.takeIf { it != NO_DOWNLOAD_ID } + } + + private fun writeDownloadId(identity: UpdateDownloadIdentity, id: Long): Boolean = + preferences.edit().putLong(downloadIdPreferenceKey(identity), id).commit() + + private fun downloadIdPreferenceKey(identity: UpdateDownloadIdentity) = + "download_id_${identity.key}" + + private fun destinationFor(identity: UpdateDownloadIdentity): File { + val downloads = requireNotNull(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)) { + "App-specific update storage is unavailable" + }.canonicalFile + check(downloads.isDirectory || downloads.mkdirs()) { "Cannot prepare update storage" } + return File(downloads, "update-${identity.key}.apk") + } + + private fun downloadManager(): DownloadManager = + requireNotNull(context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager) { + "Android DownloadManager is unavailable" + } + + private fun failureReason(reason: Int): String = when (reason) { + DownloadManager.ERROR_INSUFFICIENT_SPACE -> "insufficient_space" + DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "destination_exists" + DownloadManager.ERROR_DEVICE_NOT_FOUND -> "storage_unavailable" + DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "too_many_redirects" + DownloadManager.ERROR_UNHANDLED_HTTP_CODE, + DownloadManager.ERROR_HTTP_DATA_ERROR -> "http_error" + DownloadManager.ERROR_CANNOT_RESUME, + DownloadManager.ERROR_FILE_ERROR -> "download_io_error" + else -> "download_failed" + } + + companion object { + private const val PREFERENCES_NAME = "uth_update_downloads" + private const val APK_MIME_TYPE = "application/vnd.android.package-archive" + private const val NO_DOWNLOAD_ID = -1L + private val ACTIVE_OR_COMPLETE_STATUSES = setOf( + "pending", + "running", + "paused", + "completed", + ) + + fun isApprovedReleaseUrl(value: String): Boolean { + val uri = try { + URI(value) + } catch (_: Exception) { + return false + } + return uri.scheme.equals("https", ignoreCase = true) && + uri.host?.equals("github.com", ignoreCase = true) == true && + uri.port in setOf(-1, 443) && + uri.rawUserInfo == null && + uri.rawQuery == null && + uri.rawFragment == null && + APPROVED_RELEASE_PATH.matches(uri.rawPath.orEmpty()) + } + + private val APPROVED_RELEASE_PATH = Regex( + "/Chouwzi/UTHelper/releases/download/v[0-9]+\\.[0-9]+\\.[0-9]+/" + + "[A-Za-z0-9][A-Za-z0-9._-]{0,126}\\.apk" + ) + } +} + +private const val MAX_UPDATE_SIZE_BYTES = 8L * 1024L * 1024L * 1024L + +private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + val started = System.nanoTime() + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + require((System.nanoTime() - started) / 1_000_000 <= 20_000L) { + "Completed update hash exceeded its deadline" + } + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +private fun sha256Text(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstaller.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstaller.kt index 9ee9957..e5351dc 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstaller.kt +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstaller.kt @@ -18,8 +18,10 @@ import java.util.concurrent.atomic.AtomicReference data class InstallUpdateResult( val status: String, + val stage: String = "", val bytes: Long = 0, val sha256: String = "", + val reason: String? = null, ) data class VerifiedLocalPackage( @@ -27,6 +29,12 @@ data class VerifiedLocalPackage( val sha256: String, ) +data class PreparedUpdatePackage( + val file: File, + val verification: VerifiedLocalPackage, + val reusedCache: Boolean, +) + data class ArchiveMetadata( val packageName: String, val versionCode: Long, @@ -38,7 +46,7 @@ data class InstalledMetadata( val signerSha256: Set, ) -private fun normalizedFingerprint(value: String): String { +internal fun normalizedFingerprint(value: String): String { val normalized = value.replace(":", "").replace(" ", "").uppercase() require(normalized.matches(Regex("[0-9A-F]{64}"))) { "Certificate fingerprint must be SHA-256" @@ -122,6 +130,72 @@ fun copyAndVerifyLocalPackage( } } +fun prepareVerifiedUpdatePackage( + localPath: String, + cachedApk: File, + partialApk: File, + expectedSha256: String, + expectedSize: Long, + cancelled: AtomicBoolean = AtomicBoolean(false), +): PreparedUpdatePackage { + if (cachedApk.isFile) { + try { + return PreparedUpdatePackage( + cachedApk, + verifyExistingPackage(cachedApk, expectedSha256, expectedSize, cancelled), + reusedCache = true, + ) + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + cachedApk.delete() + } + } + + partialApk.delete() + val verification = copyAndVerifyLocalPackage( + source = File(localPath).canonicalFile, + destination = partialApk, + expectedSha256 = expectedSha256, + expectedSize = expectedSize, + cancelled = cancelled, + ) + return PreparedUpdatePackage(partialApk, verification, reusedCache = false) +} + +private fun verifyExistingPackage( + file: File, + expectedSha256: String, + expectedSize: Long, + cancelled: AtomicBoolean, +): VerifiedLocalPackage { + require(file.isFile) { "Local update package is unavailable" } + require(file.length() == expectedSize) { "Update size mismatch" } + val digest = MessageDigest.getInstance("SHA-256") + var copied = 0L + val started = System.nanoTime() + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + checkNotCancelled(cancelled) + require((System.nanoTime() - started) / 1_000_000 <= LOCAL_COPY_TIMEOUT_MILLIS) { + "Local update verification exceeded total deadline" + } + val count = input.read(buffer) + if (count < 0) break + copied += count + require(copied <= expectedSize) { "Update exceeds manifest size" } + digest.update(buffer, 0, count) + } + } + val actualSha256 = digest.digest().joinToString("") { "%02x".format(it) } + require(copied == expectedSize) { "Update size mismatch" } + require(actualSha256.equals(expectedSha256, ignoreCase = true)) { + "Update SHA-256 mismatch" + } + return VerifiedLocalPackage(copied, actualSha256) +} + class ApkUpdateInstaller(private val context: Context) { private val currentCancellation = AtomicReference(null) @@ -137,37 +211,57 @@ class ApkUpdateInstaller(private val context: Context) { expectedVersionCode: Long, expectedCertificateSha256: String, ): InstallUpdateResult { - require(expectedSha256.matches(Regex("[0-9a-fA-F]{64}"))) { - "A release SHA-256 checksum is required" - } - require(expectedSize > 0L) { "A positive release size is required" } - require(expectedPackageId == context.packageName) { "Unexpected package identity" } - require(expectedVersionCode > 0L) { "A positive versionCode is required" } - normalizedFingerprint(expectedCertificateSha256) - + var stage = "release_metadata_validation" + var keepCachedApk = false + var apk: File? = null + var partial: File? = null val cancelled = AtomicBoolean(false) - check(currentCancellation.compareAndSet(null, cancelled)) { - "An update installation handoff is already active" - } - val directory = File(context.cacheDir, "verified_updates").apply { - check(mkdirs() || isDirectory) { "Cannot prepare update cache" } + if (!currentCancellation.compareAndSet(null, cancelled)) { + return InstallUpdateResult( + status = "failed", + stage = "installer_handoff", + reason = "installer_busy", + ) } - val partial = File(directory, "UTHelper-update.apk.part") - val apk = File(directory, "UTHelper-update.apk") - partial.delete() - apk.delete() try { + require(expectedSha256.matches(Regex("[0-9a-fA-F]{64}"))) { + "A release SHA-256 checksum is required" + } + require(expectedSize > 0L) { "A positive release size is required" } + require(expectedPackageId == context.packageName) { "Unexpected package identity" } + require(expectedVersionCode > 0L) { "A positive versionCode is required" } + normalizedFingerprint(expectedCertificateSha256) + + val directory = File(context.cacheDir, "verified_updates").apply { + check(mkdirs() || isDirectory) { "Cannot prepare update cache" } + } + val releaseKey = expectedSha256.lowercase() + val stagedApk = File(directory, "update-$releaseKey.apk") + val stagedPartial = File(directory, "update-$releaseKey.apk.part") + apk = stagedApk + partial = stagedPartial checkNotCancelled(cancelled) - val verified = copyAndVerifyLocalPackage( - source = File(localPath).canonicalFile, - destination = partial, + + stage = "downloaded_package_verification" + val prepared = prepareVerifiedUpdatePackage( + localPath = localPath, + cachedApk = stagedApk, + partialApk = stagedPartial, expectedSha256 = expectedSha256, expectedSize = expectedSize, cancelled = cancelled, ) + stage = if (prepared.reusedCache) { + "cached_package_verification" + } else { + "downloaded_package_verification" + } + val verified = prepared.verification + val archivePath = prepared.file - val archive = readArchiveMetadata(partial) + stage = "archive_metadata_validation" + val archive = readArchiveMetadata(archivePath) val installed = readInstalledMetadata() validateArchiveMetadata( archive, @@ -177,48 +271,88 @@ class ApkUpdateInstaller(private val context: Context) { installed, ) checkNotCancelled(cancelled) - check(partial.renameTo(apk)) { "Cannot finalize verified APK" } - if (cancelled.get()) { - apk.delete() - throw CancellationException("Update download cancelled") + if (archivePath == stagedPartial) { + stagedApk.delete() + check(stagedPartial.renameTo(stagedApk)) { "Cannot finalize verified APK" } } + keepCachedApk = true + val packageBytes = requireNotNull(verified).bytes + val packageSha256 = requireNotNull(verified).sha256 + stage = "install_permission" if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls() ) { - context.startActivity( - Intent( - Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - Uri.parse("package:${context.packageName}"), - ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) + try { + context.startActivity( + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (_: Exception) { + return InstallUpdateResult( + status = "failed", + stage = stage, + bytes = packageBytes, + sha256 = packageSha256, + reason = "permission_settings_unavailable", + ) + } return InstallUpdateResult( - "permission_required", - verified.bytes, - verified.sha256, + status = "permission_required", + stage = stage, + bytes = packageBytes, + sha256 = packageSha256, + reason = "unknown_sources_permission_required", ) } + stage = "installer_handoff" val uri = FileProvider.getUriForFile( context, "${context.packageName}.uthupdates", - apk, + stagedApk, ) - context.startActivity( - Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } + try { + context.startActivity( + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + ) + } catch (_: Exception) { + return InstallUpdateResult( + status = "failed", + stage = stage, + bytes = packageBytes, + sha256 = packageSha256, + reason = "package_installer_unavailable", + ) + } + return InstallUpdateResult( + status = "installer_opened", + stage = stage, + bytes = packageBytes, + sha256 = packageSha256, + ) + } catch (exception: CancellationException) { + return InstallUpdateResult( + status = "cancelled", + stage = stage, + reason = "cancelled", ) + } catch (exception: Exception) { return InstallUpdateResult( - "installer_opened", - verified.bytes, - verified.sha256, + status = "failed", + stage = stage, + reason = installFailureReason(stage, exception), ) } finally { - partial.delete() + partial?.delete() + if (!keepCachedApk) apk?.delete() currentCancellation.compareAndSet(cancelled, null) } } @@ -232,6 +366,32 @@ class ApkUpdateInstaller(private val context: Context) { ) } + private fun installFailureReason(stage: String, exception: Exception): String { + val message = exception.message.orEmpty() + return when (stage) { + "release_metadata_validation" -> "release_metadata_invalid" + "downloaded_package_verification" -> when { + message == "Local update package is unavailable" -> "source_unavailable" + message.contains("size mismatch", ignoreCase = true) -> "size_mismatch" + message.contains("SHA-256 mismatch", ignoreCase = true) -> "sha256_mismatch" + message.contains("deadline", ignoreCase = true) -> "verification_timeout" + else -> "package_staging_failed" + } + "archive_metadata_validation" -> when (message) { + "Cannot parse downloaded APK" -> "invalid_apk" + "APK package ID mismatch" -> "package_mismatch" + "APK versionCode mismatch" -> "release_version_mismatch" + "APK is not a newer version" -> "already_installed" + "APK manifest signer mismatch" -> "manifest_signer_mismatch" + "APK signer does not match the installed application" -> "installed_signer_mismatch" + "APK signing data missing", "APK signer missing" -> "apk_signature_invalid" + else -> "archive_identity_unavailable" + } + "installer_handoff" -> "package_installer_unavailable" + else -> "update_verification_failed" + } + } + private fun readInstalledMetadata(): InstalledMetadata { val info = installedPackageInfo(context.packageName) return InstalledMetadata( diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManagerTest.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManagerTest.kt new file mode 100644 index 0000000..df40e92 --- /dev/null +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManagerTest.kt @@ -0,0 +1,133 @@ +package com.uthelper.backgroundsync.update + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import java.io.File +import java.security.MessageDigest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ApkUpdateDownloadManagerTest { + private val url = + "https://github.com/Chouwzi/UTHelper/releases/download/v2.5.1/UTHelper-2.5.1.apk" + private val packageBytes = "verified update package".toByteArray() + private val packageSha256 = sha256(packageBytes) + + @Test + fun startReusesPersistedRequestAndReportsProgressAfterManagerRecreation() { + val context: Context = RuntimeEnvironment.getApplication() + val identity = identity(context) + val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val shadowManager = Shadows.shadowOf(manager) + val firstDownloader = ApkUpdateDownloadManager(context) + + val started = firstDownloader.start(identity) + assertEquals("pending", started.status) + assertEquals(1, shadowManager.requestCount) + val request = shadowManager.getRequest(requireNotNull(started.downloadId)) + val shadowRequest = Shadows.shadowOf(request) + assertEquals(DownloadManager.Request.VISIBILITY_VISIBLE, shadowRequest.notificationVisibility) + assertEquals("Đang tải bản cập nhật", shadowRequest.description.toString()) + + context.getSharedPreferences("uth_update_downloads", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + + val restoredDownloader = ApkUpdateDownloadManager(context) + val restored = restoredDownloader.status(identity) + assertEquals("pending", restored.status) + assertEquals(started.downloadId, restored.downloadId) + + val reused = restoredDownloader.start(identity) + assertEquals("pending", reused.status) + assertEquals(1, shadowManager.requestCount) + + shadowRequest.setBytesSoFar(packageBytes.size.toLong() / 2) + shadowRequest.setStatus(DownloadManager.STATUS_RUNNING) + val progress = ApkUpdateDownloadManager(context).status(identity) + assertEquals("running", progress.status) + assertEquals(packageBytes.size.toLong() / 2, progress.downloaded) + assertEquals(packageBytes.size.toLong(), progress.total) + assertNull(progress.localPath) + } + + @Test + fun completedRequestReturnsOnlyChecksumVerifiedAppSpecificFileAndCanBeCancelled() { + val context: Context = RuntimeEnvironment.getApplication() + val identity = identity(context) + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val shadowManager = Shadows.shadowOf(downloadManager) + val downloader = ApkUpdateDownloadManager(context) + val started = downloader.start(identity) + val request = shadowManager.getRequest(requireNotNull(started.downloadId)) + val shadowRequest = Shadows.shadowOf(request) + val destinationUri = shadowRequest.destination + val destination = File(requireNotNull(destinationUri.path)) + assertTrue(destination.parentFile?.mkdirs() == true || destination.parentFile?.isDirectory == true) + destination.writeBytes(packageBytes) + shadowRequest.setBytesSoFar(packageBytes.size.toLong()) + shadowRequest.setStatus(DownloadManager.STATUS_SUCCESSFUL) + + val completed = ApkUpdateDownloadManager(context).status(identity) + assertEquals("completed", completed.status) + assertEquals(packageBytes.size.toLong(), completed.downloaded) + assertEquals(destination.absolutePath, completed.localPath) + assertEquals(packageSha256, sha256(File(requireNotNull(completed.localPath)).readBytes())) + assertTrue(destination.exists()) + + val reused = ApkUpdateDownloadManager(context).start(identity) + assertEquals("completed", reused.status) + assertEquals(1, shadowManager.requestCount) + + ApkUpdateDownloadManager(context).cancel(identity) + assertFalse(destination.exists()) + assertEquals("missing", ApkUpdateDownloadManager(context).status(identity).status) + } + + @Test + fun rejectsUnapprovedUrlAndWrongPinnedIdentity() { + assertTrue(ApkUpdateDownloadManager.isApprovedReleaseUrl(url)) + assertFalse(ApkUpdateDownloadManager.isApprovedReleaseUrl( + "http://github.com/Chouwzi/UTHelper/releases/download/v2.5.1/app.apk" + )) + assertFalse(ApkUpdateDownloadManager.isApprovedReleaseUrl( + "https://github.com.attacker.invalid/Chouwzi/UTHelper/releases/download/v2.5.1/app.apk" + )) + assertFalse(ApkUpdateDownloadManager.isApprovedReleaseUrl( + "https://github.com/Other/UTHelper/releases/download/v2.5.1/app.apk" + )) + + val context: Context = RuntimeEnvironment.getApplication() + val invalid = identity(context).copy(expectedPackageId = "example.attacker") + val result = ApkUpdateDownloadManager(context).let { downloader -> + runCatching { downloader.start(invalid) } + } + assertTrue(result.isFailure) + } + + private fun identity(context: Context) = UpdateDownloadIdentity( + url = url, + sha256 = packageSha256, + expectedSize = packageBytes.size.toLong(), + expectedPackageId = context.packageName, + expectedVersionCode = 2_005_001L, + expectedCertificateSha256 = "AB".repeat(32), + ) + + private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } +} diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt new file mode 100644 index 0000000..f2b0eb0 --- /dev/null +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt @@ -0,0 +1,161 @@ +package com.uthelper.backgroundsync.update + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.Signature +import android.net.Uri +import android.provider.Settings +import java.io.File +import java.security.MessageDigest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** Robolectric simulates Android framework metadata and activity handoff, not APK parsing/install. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [27]) +class ApkUpdateInstallerFlowTest { + private val archiveBytes = "platform handoff fixture".toByteArray() + private val signerBytes = "UTHelper signer fixture".toByteArray() + + @Test + fun validatesPinnedReleaseThenOpensPackageInstallerIntent() { + val app = RuntimeEnvironment.getApplication() + val context: Context = app + val packageFile = fixturePackage(context) + val identity = installIdentity(context, packageFile) + val installer = ApkUpdateInstaller(context) + Shadows.shadowOf(context.packageManager).setCanRequestPackageInstalls(true) + + val result = installer.verifyLocalAndOpen( + localPath = packageFile.absolutePath, + expectedSha256 = identity.sha256, + expectedSize = archiveBytes.size.toLong(), + expectedPackageId = context.packageName, + expectedVersionCode = identity.versionCode, + expectedCertificateSha256 = identity.certificateSha256, + ) + + assertEquals("installer_opened", result.status) + assertEquals("installer_handoff", result.stage) + assertEquals(archiveBytes.size.toLong(), result.bytes) + assertEquals(identity.sha256, result.sha256) + val launch = Shadows.shadowOf(app).getNextStartedActivity() + assertNotNull(launch) + assertEquals(Intent.ACTION_VIEW, launch!!.action) + assertEquals("application/vnd.android.package-archive", launch.type) + assertTrue(launch.data.toString().startsWith("content://")) + assertTrue( + launch.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0 + ) + assertTrue( + launch.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0 + ) + } + + @Test + fun permissionReturnRetriesTheSameVerifiedCacheAfterSourceIsGone() { + val app = RuntimeEnvironment.getApplication() + val context: Context = app + val packageFile = fixturePackage(context) + val identity = installIdentity(context, packageFile) + val installer = ApkUpdateInstaller(context) + val shadowPackageManager = Shadows.shadowOf(context.packageManager) + shadowPackageManager.setCanRequestPackageInstalls(false) + + val permission = installer.verifyLocalAndOpen( + localPath = packageFile.absolutePath, + expectedSha256 = identity.sha256, + expectedSize = archiveBytes.size.toLong(), + expectedPackageId = context.packageName, + expectedVersionCode = identity.versionCode, + expectedCertificateSha256 = identity.certificateSha256, + ) + + assertEquals("permission_required", permission.status) + assertEquals("install_permission", permission.stage) + val settingsIntent = Shadows.shadowOf(app).getNextStartedActivity() + assertNotNull(settingsIntent) + assertEquals(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, settingsIntent!!.action) + assertEquals(Uri.parse("package:${context.packageName}"), settingsIntent.data) + + val staged = File( + File(context.cacheDir, "verified_updates"), + "update-${identity.sha256}.apk", + ) + assertTrue(staged.isFile) + packageFile.delete() + shadowPackageManager.setCanRequestPackageInstalls(true) + + val retried = installer.verifyLocalAndOpen( + localPath = "", + expectedSha256 = identity.sha256, + expectedSize = archiveBytes.size.toLong(), + expectedPackageId = context.packageName, + expectedVersionCode = identity.versionCode, + expectedCertificateSha256 = identity.certificateSha256, + ) + + assertEquals("installer_opened", retried.status) + assertEquals("installer_handoff", retried.stage) + assertEquals(identity.sha256, retried.sha256) + val installerIntent = Shadows.shadowOf(app).getNextStartedActivity() + assertNotNull(installerIntent) + assertEquals(Intent.ACTION_VIEW, installerIntent!!.action) + assertEquals("application/vnd.android.package-archive", installerIntent.type) + } + + private fun fixturePackage(context: Context): File = + File(context.cacheDir, "test-release.apk").apply { + writeBytes(archiveBytes) + }.canonicalFile + + private fun installIdentity(context: Context, packageFile: File): InstallIdentity { + val signature = Signature(signerBytes) + val installed = PackageInfo().apply { + packageName = context.packageName + versionCode = 2_005_000 + signatures = arrayOf(signature) + } + val archive = PackageInfo().apply { + packageName = context.packageName + versionCode = 2_005_001 + signatures = arrayOf(signature) + } + val packageManager = Shadows.shadowOf(context.packageManager) + packageManager.installPackage(installed) + packageManager.setPackageArchiveInfo(packageFile.absolutePath, archive) + val cache = File(context.cacheDir, "verified_updates") + val releaseKey = sha256(archiveBytes) + packageManager.setPackageArchiveInfo( + File(cache, "update-$releaseKey.apk.part").canonicalPath, + archive, + ) + packageManager.setPackageArchiveInfo( + File(cache, "update-$releaseKey.apk").canonicalPath, + archive, + ) + return InstallIdentity( + sha256(archiveBytes), + archive.versionCode.toLong(), + sha256(signature.toByteArray()), + ) + } + + private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + + private data class InstallIdentity( + val sha256: String, + val versionCode: Long, + val certificateSha256: String, + ) +} diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerTest.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerTest.kt index 362d9aa..aac6d11 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerTest.kt +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerTest.kt @@ -1,7 +1,11 @@ package com.uthelper.backgroundsync.update import java.nio.file.Files +import java.util.concurrent.CancellationException +import java.util.concurrent.atomic.AtomicBoolean import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Assert.assertThrows import org.junit.Test @@ -140,4 +144,104 @@ class ApkUpdateInstallerTest { directory.deleteRecursively() } } + + @Test + fun permissionRetryCanReuseSameVerifiedCacheAfterSourceDisappears() { + val directory = Files.createTempDirectory("uthelper-permission-retry-test").toFile() + try { + val contents = "verified permission retry".toByteArray() + val expectedSha256 = sha256(contents) + val source = directory.resolve("download-manager.apk").apply { + writeBytes(contents) + } + val cached = directory.resolve("update-$expectedSha256.apk") + val partial = directory.resolve("update-$expectedSha256.apk.part") + + val first = prepareVerifiedUpdatePackage( + localPath = source.absolutePath, + cachedApk = cached, + partialApk = partial, + expectedSha256 = expectedSha256, + expectedSize = contents.size.toLong(), + ) + assertFalse(first.reusedCache) + assertEquals(partial, first.file) + assertTrue(partial.renameTo(cached)) + source.delete() + + val retry = prepareVerifiedUpdatePackage( + localPath = "", + cachedApk = cached, + partialApk = partial, + expectedSha256 = expectedSha256, + expectedSize = contents.size.toLong(), + ) + assertTrue(retry.reusedCache) + assertEquals(cached, retry.file) + assertEquals(contents.size.toLong(), retry.verification.bytes) + assertEquals(expectedSha256, retry.verification.sha256) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun permissionRetryRejectsTamperedCacheWhenOriginalSourceIsGone() { + val directory = Files.createTempDirectory("uthelper-permission-retry-test").toFile() + try { + val contents = "verified permission retry".toByteArray() + val expectedSha256 = sha256(contents) + val cached = directory.resolve("update-$expectedSha256.apk").apply { + writeBytes(contents) + } + val partial = directory.resolve("update-$expectedSha256.apk.part") + cached.writeBytes("tampered package contents".toByteArray()) + + assertThrows(IllegalArgumentException::class.java) { + prepareVerifiedUpdatePackage( + localPath = "", + cachedApk = cached, + partialApk = partial, + expectedSha256 = expectedSha256, + expectedSize = contents.size.toLong(), + ) + } + assertFalse(cached.exists()) + assertFalse(partial.exists()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun cancellationDuringCachedVerificationPreservesValidCache() { + val directory = Files.createTempDirectory("uthelper-cancel-cache-test").toFile() + try { + val contents = "verified permission retry".toByteArray() + val expectedSha256 = sha256(contents) + val cached = directory.resolve("update-$expectedSha256.apk").apply { + writeBytes(contents) + } + val partial = directory.resolve("update-$expectedSha256.apk.part") + + assertThrows(CancellationException::class.java) { + prepareVerifiedUpdatePackage( + localPath = "", + cachedApk = cached, + partialApk = partial, + expectedSha256 = expectedSha256, + expectedSize = contents.size.toLong(), + cancelled = AtomicBoolean(true), + ) + } + assertTrue(cached.exists()) + assertFalse(partial.exists()) + } finally { + directory.deleteRecursively() + } + } + + private fun sha256(bytes: ByteArray): String = java.security.MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } } diff --git a/extensions/flet_uth_background_sync/src/flet_uth_background_sync/background_sync.py b/extensions/flet_uth_background_sync/src/flet_uth_background_sync/background_sync.py index 7590aa2..7d30235 100644 --- a/extensions/flet_uth_background_sync/src/flet_uth_background_sync/background_sync.py +++ b/extensions/flet_uth_background_sync/src/flet_uth_background_sync/background_sync.py @@ -90,6 +90,76 @@ async def install_update( "expected_version_code": expected_version_code, "expected_certificate_sha256": expected_certificate_sha256, }, + timeout=190, + ) + + async def start_update_download( + self, + url: str, + sha256: str, + expected_size: int, + expected_package_id: str, + expected_version_code: int, + expected_certificate_sha256: str, + ) -> dict[str, Any]: + """Start or resume the OS-managed download for one pinned Android release.""" + return await self._invoke_method( + "start_update_download", + { + "url": url, + "sha256": sha256, + "expected_size": expected_size, + "expected_package_id": expected_package_id, + "expected_version_code": expected_version_code, + "expected_certificate_sha256": expected_certificate_sha256, + }, + timeout=30, + ) + + async def get_update_download_status( + self, + url: str, + sha256: str, + expected_size: int, + expected_package_id: str, + expected_version_code: int, + expected_certificate_sha256: str, + ) -> dict[str, Any]: + """Read durable progress for the same release metadata passed to start.""" + return await self._invoke_method( + "get_update_download_status", + { + "url": url, + "sha256": sha256, + "expected_size": expected_size, + "expected_package_id": expected_package_id, + "expected_version_code": expected_version_code, + "expected_certificate_sha256": expected_certificate_sha256, + }, + timeout=30, + ) + + async def cancel_update_download( + self, + url: str, + sha256: str, + expected_size: int, + expected_package_id: str, + expected_version_code: int, + expected_certificate_sha256: str, + ) -> None: + """Cancel the OS download and remove its app-specific cached package.""" + await self._invoke_method( + "cancel_update_download", + { + "url": url, + "sha256": sha256, + "expected_size": expected_size, + "expected_package_id": expected_package_id, + "expected_version_code": expected_version_code, + "expected_certificate_sha256": expected_certificate_sha256, + }, + timeout=30, ) async def cancel_update(self) -> None: diff --git a/extensions/flet_uth_background_sync/tests/test_package_contract.py b/extensions/flet_uth_background_sync/tests/test_package_contract.py index 3b1c02b..238d0c6 100644 --- a/extensions/flet_uth_background_sync/tests/test_package_contract.py +++ b/extensions/flet_uth_background_sync/tests/test_package_contract.py @@ -73,6 +73,34 @@ def test_android_updater_requires_local_checksum_and_package_installer(): assert "expectedVersionCode" in updater assert "expectedCertificateSha256" in updater assert "AtomicBoolean" in updater + assert '"stage" to update.stage' in next(ANDROID.rglob("UthBackgroundSyncPlugin.kt")).read_text( + encoding="utf-8" + ) + assert "REQUEST_INSTALL_PACKAGES" in ( + ANDROID / "src/main/AndroidManifest.xml" + ).read_text(encoding="utf-8") + + +def test_android_download_uses_pinned_durable_download_manager_request(): + downloader = next(ANDROID.rglob("ApkUpdateDownloadManager.kt")).read_text( + encoding="utf-8" + ) + manifest = (ANDROID / "src/main/AndroidManifest.xml").read_text(encoding="utf-8") + + assert "DownloadManager.Request" in downloader + assert '"pending"' in downloader + assert '"running"' in downloader + assert '"paused"' in downloader + assert '"completed"' in downloader + assert '"failed"' in downloader + assert '"missing"' in downloader + assert "setDestinationInExternalFilesDir" in downloader + assert "VISIBILITY_VISIBLE)" in downloader + assert "VISIBILITY_VISIBLE_NOTIFY_COMPLETED" not in downloader + assert '"Đang tải bản cập nhật"' in downloader + assert 'uri.host?.equals("github.com", ignoreCase = true)' in downloader + assert "REQUEST_INSTALL_PACKAGES" in manifest + assert "getExternalFilesDir" in downloader def test_python_service_forwards_configuration(monkeypatch): @@ -81,8 +109,10 @@ def test_python_service_forwards_configuration(monkeypatch): service = AndroidBackgroundSync() captured = {} - async def invoke(name, arguments=None): + async def invoke(name, arguments=None, timeout=None): captured.update(name=name, arguments=arguments) + if timeout is not None: + captured["timeout"] = timeout return {"enabled": True} monkeypatch.setattr(service, "_invoke_method", invoke) @@ -125,6 +155,7 @@ async def invoke(name, arguments=None): "expected_version_code": 2_002_000, "expected_certificate_sha256": "cd" * 32, }, + "timeout": 190, } asyncio.run(service.cancel_update()) @@ -132,6 +163,48 @@ async def invoke(name, arguments=None): assert captured["arguments"] is None +def test_python_service_forwards_android_update_download_identity_and_deadlines(monkeypatch): + from flet_uth_background_sync import AndroidBackgroundSync + + service = AndroidBackgroundSync() + captured = {} + + async def invoke(name, arguments=None, timeout=None): + captured.update(name=name, arguments=arguments, timeout=timeout) + return {"status": "pending"} + + monkeypatch.setattr(service, "_invoke_method", invoke) + identity = ( + "https://github.com/Chouwzi/UTHelper/releases/download/v2.5.1/UTHelper-2.5.1.apk", + "ab" * 32, + 123, + "com.uthelper.uthelper", + 2_005_001, + "cd" * 32, + ) + asyncio.run(service.start_update_download(*identity)) + assert captured == { + "name": "start_update_download", + "arguments": { + "url": identity[0], + "sha256": identity[1], + "expected_size": identity[2], + "expected_package_id": identity[3], + "expected_version_code": identity[4], + "expected_certificate_sha256": identity[5], + }, + "timeout": 30, + } + + asyncio.run(service.get_update_download_status(*identity)) + assert captured["name"] == "get_update_download_status" + assert captured["timeout"] == 30 + + asyncio.run(service.cancel_update_download(*identity)) + assert captured["name"] == "cancel_update_download" + assert captured["timeout"] == 30 + + def test_flutter_package_registers_ios_user_notifications_adapter(): pubspec = (FLUTTER / "pubspec.yaml").read_text(encoding="utf-8") swift = (IOS / "Classes/UthBackgroundSyncPlugin.swift").read_text( diff --git a/pyproject.toml b/pyproject.toml index 7368e24..7584889 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "uthelper" -version = "2.5.0" +version = "2.5.1" description = "UTHelper — Ứng dụng theo dõi bài tập và deadline UTH E-learning" readme = "README.md" requires-python = ">=3.11" diff --git a/src/core/update_checker.py b/src/core/update_checker.py index f88f36e..ce6a51b 100644 --- a/src/core/update_checker.py +++ b/src/core/update_checker.py @@ -36,6 +36,8 @@ _MANIFEST_NAME = "release-manifest.json" _DOWNLOAD_CHUNK_SIZE = 64 * 1024 _SOCKET_TIMEOUT_SECONDS = 20 +_DEFAULT_DOWNLOAD_TIMEOUT_SECONDS = 600.0 +_MAX_DOWNLOAD_TIMEOUT_SECONDS = 600.0 _MAX_JSON_BYTES = 1024 * 1024 _APPROVED_GITHUB_HOSTS = frozenset( {"github.com", "objects.githubusercontent.com"} @@ -59,6 +61,10 @@ class PackageIntegrityError(UpdateError): """Downloaded bytes do not match the selected release package.""" +class _IncompleteDownloadError(OSError): + """The response ended before the complete package arrived.""" + + @dataclass(frozen=True) class UpdateAsset: platform: str @@ -270,12 +276,16 @@ class VerifiedDownloader: def __init__( self, cache_dir: Path | None = None, - total_timeout_seconds: float = 180.0, + total_timeout_seconds: float = _DEFAULT_DOWNLOAD_TIMEOUT_SECONDS, ) -> None: self.cache_dir = Path(cache_dir) if cache_dir is not None else _get_update_temp_dir() timeout = float(total_timeout_seconds) - if not math.isfinite(timeout) or timeout <= 0 or timeout > 180: - raise ValueError("update total timeout must be within 180 seconds") + if ( + not math.isfinite(timeout) + or timeout <= 0 + or timeout > _MAX_DOWNLOAD_TIMEOUT_SECONDS + ): + raise ValueError("update total timeout must be within 600 seconds") self.total_timeout_seconds = timeout @staticmethod @@ -303,12 +313,22 @@ def download( raise TypeError("package must be ReleasePackage") if not isinstance(cancel, threading.Event): raise TypeError("cancel must be threading.Event") + started = time.monotonic() + deadline = started + self.total_timeout_seconds self.cache_dir.mkdir(parents=True, exist_ok=True) if not self.cache_dir.is_dir(): raise PackageIntegrityError("update cache is unavailable") destination = self.cache_dir / self._filename(package) partial = destination.with_suffix(destination.suffix + ".part") sidecar = partial.with_suffix(partial.suffix + ".json") + if self._is_verified_cache(destination, package, cancel, deadline): + if cancel.is_set(): + raise DownloadCancelled("download cancelled") + self._remaining_seconds(deadline) + if progress is not None: + progress(package.size, package.size) + return destination + expected_state = { "url": package.url, "sha256": package.sha256.lower(), @@ -320,30 +340,61 @@ def download( if progress is not None else None ) - if throttled_progress is not None: - throttled_progress(partial.stat().st_size if partial.exists() else 0) - started = time.monotonic() try: if cancel.is_set(): raise DownloadCancelled("download cancelled") self._write_sidecar(sidecar, expected_state) + if partial.exists() and partial.stat().st_size == package.size: + self._verify_complete_partial( + partial, + package, + cancel=cancel, + deadline=deadline, + ) + if cancel.is_set(): + raise DownloadCancelled("download cancelled") + self._remaining_seconds(deadline) + os.replace(partial, destination) + sidecar.unlink(missing_ok=True) + if throttled_progress is not None: + throttled_progress(package.size) + return destination + + if throttled_progress is not None: + throttled_progress(partial.stat().st_size if partial.exists() else 0) for attempt in range(3): try: self._download_attempt( package, partial, cancel=cancel, - started=started, + deadline=deadline, progress=throttled_progress, ) break - except (OSError, urllib.error.URLError, TimeoutError): + except (OSError, urllib.error.URLError, TimeoutError) as exc: if cancel.is_set(): raise DownloadCancelled("download cancelled") + remaining = self._remaining_seconds(deadline) if attempt == 2: raise - time.sleep(0.25 * (2**attempt)) - self._verify_complete_partial(partial, package) + delay = 0.25 * (2**attempt) + if remaining <= delay: + raise TimeoutError( + "update download exceeded total deadline" + ) from exc + if cancel.wait(delay): + raise DownloadCancelled("download cancelled") from exc + self._remaining_seconds(deadline) + self._verify_complete_partial( + partial, + package, + cancel=cancel, + deadline=deadline, + ) + if cancel.is_set(): + raise DownloadCancelled("download cancelled") + self._remaining_seconds(deadline) os.replace(partial, destination) sidecar.unlink(missing_ok=True) if throttled_progress is not None: @@ -360,6 +411,35 @@ def download( sidecar.unlink(missing_ok=True) raise + @staticmethod + def _remaining_seconds(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("update download exceeded total deadline") + return remaining + + @staticmethod + def _is_verified_cache( + destination: Path, + package: ReleasePackage, + cancel: threading.Event, + deadline: float, + ) -> bool: + if destination.is_symlink() or not destination.is_file(): + return False + try: + VerifiedDownloader._verify_complete_partial( + destination, + package, + cancel=cancel, + deadline=deadline, + ) + except TimeoutError: + raise + except (OSError, PackageIntegrityError): + return False + return True + @staticmethod def _prepare_partial( partial: Path, @@ -375,7 +455,7 @@ def _prepare_partial( reusable = ( not partial.is_symlink() and partial.is_file() - and partial.stat().st_size < expected_size + and partial.stat().st_size <= expected_size and state == expected_state ) except (OSError, UnicodeError, ValueError, json.JSONDecodeError): @@ -398,7 +478,7 @@ def _download_attempt( partial: Path, *, cancel: threading.Event, - started: float, + deadline: float, progress: ProgressThrottler | None, ) -> None: offset = partial.stat().st_size if partial.exists() else 0 @@ -406,13 +486,23 @@ def _download_attempt( if offset: headers["Range"] = f"bytes={offset}-" request = urllib.request.Request(package.url, headers=headers) + request_timeout = min( + float(_SOCKET_TIMEOUT_SECONDS), + self._remaining_seconds(deadline), + ) with urllib.request.urlopen( request, - timeout=_SOCKET_TIMEOUT_SECONDS, + timeout=request_timeout, ) as response: + self._remaining_seconds(deadline) status = int(getattr(response, "status", 200)) + response_end: int | None = None if offset and status == 206: - self._validate_content_range(response, offset, package.size) + response_end = self._validate_content_range( + response, + offset, + package.size, + ) mode = "ab" downloaded = offset elif offset and status == 200: @@ -424,43 +514,87 @@ def _download_attempt( else: raise PackageIntegrityError("update range response is invalid") with partial.open(mode) as output: + received = 0 while True: if cancel.is_set(): raise DownloadCancelled("download cancelled") - if time.monotonic() - started > self.total_timeout_seconds: - raise TimeoutError("update download exceeded total deadline") + remaining = self._remaining_seconds(deadline) + self._set_response_timeout( + response, + min(float(_SOCKET_TIMEOUT_SECONDS), remaining), + ) chunk = response.read(_DOWNLOAD_CHUNK_SIZE) + self._remaining_seconds(deadline) + if cancel.is_set(): + raise DownloadCancelled("download cancelled") if not chunk: break output.write(chunk) downloaded += len(chunk) + received += len(chunk) if downloaded > package.size: raise PackageIntegrityError("download exceeds manifest size") if progress is not None: progress(downloaded) + if response_end is not None: + expected_response_size = response_end - offset + 1 + if received != expected_response_size: + raise _IncompleteDownloadError( + "update range response ended before its declared size" + ) + if downloaded < package.size: + raise _IncompleteDownloadError( + "update response ended before the package was complete" + ) output.flush() os.fsync(output.fileno()) @staticmethod - def _validate_content_range(response, offset: int, total: int) -> None: + def _set_response_timeout(response, timeout: float) -> None: + """Tighten urllib's socket timeout as the operation deadline approaches.""" + stream = getattr(response, "fp", None) + raw = getattr(stream, "raw", None) + connection = getattr(raw, "_sock", None) + settimeout = getattr(connection, "settimeout", None) + if callable(settimeout): + settimeout(timeout) + + @staticmethod + def _validate_content_range(response, offset: int, total: int) -> int: value = str(response.headers.get("Content-Range", "")) match = _CONTENT_RANGE.fullmatch(value) if ( match is None or int(match.group(1)) != offset or int(match.group(2)) < offset + or int(match.group(2)) >= total or int(match.group(3)) != total ): raise PackageIntegrityError("update content range is invalid") + return int(match.group(2)) @staticmethod - def _verify_complete_partial(partial: Path, package: ReleasePackage) -> None: + def _verify_complete_partial( + partial: Path, + package: ReleasePackage, + *, + cancel: threading.Event | None = None, + deadline: float | None = None, + ) -> None: if partial.stat().st_size != package.size: raise PackageIntegrityError("update size mismatch") digest = hashlib.sha256() with partial.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): + if cancel is not None and cancel.is_set(): + raise DownloadCancelled("download cancelled") + if deadline is not None: + VerifiedDownloader._remaining_seconds(deadline) digest.update(chunk) + if cancel is not None and cancel.is_set(): + raise DownloadCancelled("download cancelled") + if deadline is not None: + VerifiedDownloader._remaining_seconds(deadline) if digest.hexdigest().lower() != package.sha256.lower(): raise PackageIntegrityError("update SHA-256 mismatch") diff --git a/src/core/update_coordinator.py b/src/core/update_coordinator.py index cad8bdd..7c4636c 100644 --- a/src/core/update_coordinator.py +++ b/src/core/update_coordinator.py @@ -50,8 +50,12 @@ class UpdateEventKind(str, Enum): UP_TO_DATE = "up_to_date" DOWNLOADING = "downloading" DOWNLOAD_PROGRESS = "download_progress" + VERIFYING = "verifying" READY_TO_INSTALL = "ready_to_install" MANUAL_DOWNLOAD_REQUIRED = "manual_download_required" + INSTALLING = "installing" + INSTALL_PERMISSION_REQUIRED = "install_permission_required" + INSTALL_FAILED = "install_failed" INSTALL_LAUNCHED = "install_launched" CANCELLED = "cancelled" FAILED = "failed" @@ -116,12 +120,14 @@ def __init__( self._automatic_check_queued = False self._manual_check_queued = False self._download_queued = False + self._download_in_progress = False self._confirm_queued = False self._preference_queued = False self._candidate: UpdateCandidate | None = None self._ready_path: Path | None = None self._ready_external_url: str | None = None self._install_handed_off = False + self._detached_downloader = False def start(self) -> None: with self._lock: @@ -168,13 +174,25 @@ def request_download(self, candidate: UpdateCandidate | None = None) -> None: else: self._ready_path = None if ready_event is None: - if self._candidate is None or self._download_queued: + if ( + self._candidate is None + or self._download_queued + or self._download_in_progress + ): return if self._enqueue_locked(self._DOWNLOAD): self._download_queued = True if ready_event is not None: self._emit(ready_event, candidate=ready_candidate, progress=1.0) + def cancel_download(self) -> None: + """Cooperatively cancel the current user-requested package download.""" + with self._lock: + if self._closed: + return + if self._download_in_progress: + self._cancel_download.set() + def confirm_install(self) -> None: with self._lock: self._ensure_open_locked() @@ -195,14 +213,19 @@ def shutdown(self, timeout_seconds: float = 5.0) -> bool: with self._lock: if self._closed: thread = self._thread + first_shutdown = False else: self._closed = True self._stop.set() - self._cancel_download.set() + first_shutdown = True if self._started and not self._install_handed_off: self._cancel_launcher() - self._enqueue_locked(self._SHUTDOWN) thread = self._thread + if first_shutdown: + self._detach_downloader() + with self._lock: + self._cancel_download.set() + self._enqueue_locked(self._SHUTDOWN) if thread is None or thread is threading.current_thread(): return True thread.join(timeout) @@ -283,7 +306,13 @@ def _run(self) -> None: elif command == self._DOWNLOAD: with self._lock: self._download_queued = False - self._perform_download() + self._cancel_download.clear() + self._download_in_progress = True + try: + self._perform_download() + finally: + with self._lock: + self._download_in_progress = False elif command == self._CONFIRM: with self._lock: self._confirm_queued = False @@ -305,14 +334,35 @@ def _perform_check(self) -> None: if self._stop.is_set(): return with self._lock: - self._candidate = candidate - self._ready_path = None - self._ready_external_url = None - self._install_handed_off = False + if candidate != self._candidate: + self._candidate = candidate + self._ready_path = None + self._ready_external_url = None + self._install_handed_off = False + elif candidate is None: + self._ready_path = None + self._ready_external_url = None + self._install_handed_off = False + elif self._ready_path is not None and not self._ready_path.is_file(): + self._ready_path = None if candidate is None: self._emit(UpdateEventKind.UP_TO_DATE) else: self._emit(UpdateEventKind.UPDATE_AVAILABLE, candidate=candidate) + has_pending_download = getattr( + self._downloader, "has_pending_download", None + ) + if callable(has_pending_download): + try: + pending = bool(has_pending_download(candidate.package)) + except Exception: + logger.warning( + "Could not inspect a pending update download", + exc_info=True, + ) + pending = False + if pending and not self._stop.is_set(): + self.request_download(candidate) def _perform_download(self) -> None: with self._lock: @@ -322,6 +372,7 @@ def _perform_download(self) -> None: if candidate.manifest.schema_version == 1: with self._lock: self._ready_external_url = candidate.manifest.release_notes_url + self._finish_download() self._emit( UpdateEventKind.MANUAL_DOWNLOAD_REQUIRED, candidate=candidate, @@ -341,6 +392,7 @@ def _perform_download(self) -> None: or set(package.install_strategy) != {"kind"} or candidate.manifest.release_notes_url != expected_url ): + self._finish_download() self._emit( UpdateEventKind.FAILED, candidate=candidate, @@ -349,6 +401,7 @@ def _perform_download(self) -> None: return with self._lock: self._ready_external_url = expected_url + self._finish_download() self._emit( UpdateEventKind.MANUAL_DOWNLOAD_REQUIRED, candidate=candidate, @@ -370,14 +423,15 @@ def _perform_download(self) -> None: or port not in (None, 443) or parsed.fragment ): + self._finish_download() self._emit(UpdateEventKind.FAILED, candidate=candidate, message="missing store URL") return with self._lock: self._ready_external_url = store_url + self._finish_download() self._emit(UpdateEventKind.READY_TO_INSTALL, candidate=candidate) return - self._cancel_download.clear() self._emit(UpdateEventKind.DOWNLOADING, candidate=candidate, progress=0.0) downloaded_path: Path | None = None try: @@ -392,30 +446,41 @@ def _perform_download(self) -> None: ) if self._cancel_download.is_set() or self._stop.is_set(): raise DownloadCancelled("download cancelled") + self._emit(UpdateEventKind.VERIFYING, candidate=candidate) result = self._verifier.verify(downloaded_path, candidate) if not result.verified: downloaded_path.unlink(missing_ok=True) downloaded_path = None + self._finish_download() self._emit(UpdateEventKind.FAILED, candidate=candidate, message=result.reason) return with self._lock: self._ready_path = downloaded_path + self._finish_download() self._emit(UpdateEventKind.READY_TO_INSTALL, candidate=candidate, progress=1.0) except DownloadCancelled: self._remove_downloaded_path(downloaded_path) + self._finish_download() self._emit(UpdateEventKind.CANCELLED, candidate=candidate) except Exception as exc: self._remove_downloaded_path(downloaded_path) + self._finish_download() if self._cancel_download.is_set(): self._emit(UpdateEventKind.CANCELLED, candidate=candidate) else: logger.warning("Update download or verification failed", exc_info=True) self._emit(UpdateEventKind.FAILED, candidate=candidate, message=str(exc)) - @staticmethod - def _remove_downloaded_path(path: Path | None) -> None: + def _finish_download(self) -> None: + with self._lock: + self._download_in_progress = False + + def _remove_downloaded_path(self, path: Path | None) -> None: if path is None: return + with self._lock: + if self._detached_downloader and self._stop.is_set(): + return try: path.unlink(missing_ok=True) except OSError: @@ -430,10 +495,16 @@ def _perform_confirmation(self) -> None: return try: if external_url is not None: + self._emit(UpdateEventKind.INSTALLING, candidate=candidate) acknowledged = bool(self._external_opener(external_url)) reason = "" elif path is not None: - verification = self._verifier.verify(path, candidate) + try: + verification = self._verifier.verify(path, candidate) + except Exception as exc: + logger.warning("Update package re-verification failed", exc_info=True) + self._emit(UpdateEventKind.FAILED, candidate=candidate, message=str(exc)) + return if not verification.verified: self._remove_downloaded_path(path) with self._lock: @@ -444,22 +515,54 @@ def _perform_confirmation(self) -> None: message=verification.reason, ) return - result = self._launcher.launch(path, candidate.package) - acknowledged = result.acknowledged - reason = result.reason + self._emit(UpdateEventKind.INSTALLING, candidate=candidate) + try: + result = self._launcher.launch(path, candidate.package) + except Exception as exc: + logger.warning("Update installer launch failed", exc_info=True) + self._emit( + UpdateEventKind.INSTALL_FAILED, + candidate=candidate, + message=str(exc), + ) + return + if result.status == "permission_required": + event_kind = UpdateEventKind.INSTALL_PERMISSION_REQUIRED + elif result.acknowledged: + event_kind = UpdateEventKind.INSTALL_LAUNCHED + else: + event_kind = UpdateEventKind.INSTALL_FAILED + reason = result.reason or result.status else: return - if acknowledged: + if external_url is not None: + event_kind = ( + UpdateEventKind.INSTALL_LAUNCHED + if acknowledged + else UpdateEventKind.INSTALL_FAILED + ) + if event_kind is UpdateEventKind.INSTALL_LAUNCHED: with self._lock: self._install_handed_off = True + self._emit(event_kind, candidate=candidate, message=reason) + except Exception as exc: + logger.warning("Update installer launch failed", exc_info=True) self._emit( - UpdateEventKind.INSTALL_LAUNCHED if acknowledged else UpdateEventKind.FAILED, + UpdateEventKind.INSTALL_FAILED, candidate=candidate, - message=reason, + message=str(exc), ) - except Exception as exc: - logger.warning("Update installer launch failed", exc_info=True) - self._emit(UpdateEventKind.FAILED, candidate=candidate, message=str(exc)) + + def _detach_downloader(self) -> None: + detach = getattr(self._downloader, "detach", None) + if not callable(detach): + return + with self._lock: + self._detached_downloader = True + try: + detach() + except Exception: + logger.warning("Update downloader detach failed", exc_info=True) def _cancel_launcher(self) -> None: try: diff --git a/src/core/update_models.py b/src/core/update_models.py index ddba52a..9c2ab83 100644 --- a/src/core/update_models.py +++ b/src/core/update_models.py @@ -57,6 +57,7 @@ class VerificationResult: class LaunchResult: acknowledged: bool reason: str = "" + status: str = "" __all__ = [ diff --git a/src/gui/app_controller.py b/src/gui/app_controller.py index b8b34e2..dd75b2e 100644 --- a/src/gui/app_controller.py +++ b/src/gui/app_controller.py @@ -17,7 +17,6 @@ import threading from pathlib import Path -from packaging.version import InvalidVersion, Version from core.data_orchestrator import DataOrchestrator from core.moodle_service import MoodleService @@ -36,6 +35,10 @@ from core.update_checker import GitHubReleaseClient, VerifiedDownloader from core.update_coordinator import UpdateCoordinator, UpdateEvent, UpdateEventKind from core.update_models import LaunchResult, ReleasePackage +from platform_utils.android_updates import ( + AndroidPackageDownloader, + AndroidPackageLauncher, +) from config import get_sync_interval_minutes, save_settings, settings from platform_utils.update_packages import ( DownloadedPackageVerifier, @@ -91,26 +94,6 @@ def _save_setting(key: str, value): logging.getLogger(__name__).warning(f"Failed to save setting {key}: {e}") -def _android_release_build_number(version: str) -> int: - """Derive the Flet Android build number from a strict X.Y.Z version.""" - try: - parsed = Version(version) - except InvalidVersion as exc: - raise ValueError("invalid Android release version") from exc - if ( - str(parsed) != version - or len(parsed.release) != 3 - or parsed.pre - or parsed.post - or parsed.dev - or parsed.local - or any(part < 0 or part > 999 for part in parsed.release) - ): - raise ValueError("Android release version must be numeric X.Y.Z") - major, minor, patch = parsed.release - return major * 1_000_000 + minor * 1_000 + patch - - class _UnavailablePackageLauncher: def launch(self, _path: Path, _package: ReleasePackage) -> LaunchResult: return LaunchResult(False, "installer is unavailable on this platform") @@ -119,65 +102,27 @@ def cancel(self) -> None: return None -class _AndroidPackageLauncher: - """Thin adapter from the synchronous coordinator to the native bridge.""" - - PACKAGE_ID = "com.uthelper.uthelper" - - def __init__(self, bridge, task_runner, release_version_provider, result_sink=None): - self._bridge = bridge - self._task_runner = task_runner - self._release_version_provider = release_version_provider - self._result_sink = result_sink - - def launch(self, path: Path, package: ReleasePackage) -> LaunchResult: - if ( - package.platform != "android" - or package.package_type != "apk" - or self._bridge is None - or not self._bridge.available - ): - return LaunchResult(False, "Android installer bridge is unavailable") - try: - version_code = _android_release_build_number( - self._release_version_provider() - ) - task = self._task_runner(self._install, Path(path), package, version_code) - except (RuntimeError, TypeError, ValueError): - logger.warning("Android installer scheduling failed", exc_info=True) - return LaunchResult(False, "Android installer scheduling failed") - return LaunchResult(task is not None, "Android installer scheduled") - - async def _install( - self, - path: Path, - package: ReleasePackage, - version_code: int, - ) -> None: - try: - result = await self._bridge.install_update( - str(path), - package.sha256, - package.size, - self.PACKAGE_ID, - version_code, - package.certificate_fingerprint, - ) - except Exception: - logger.warning("Android installer bridge failed", exc_info=True) - result = {"status": "bridge_failed"} - if self._result_sink is not None: - self._result_sink(result) - if result.get("status") not in {"installer_opened", "permission_required"}: - logger.warning("Android installer rejected update: %s", result.get("status")) - - def cancel(self) -> None: - if self._bridge is None or not self._bridge.available: - return - try: - self._task_runner(self._bridge.cancel_update) - except (RuntimeError, TypeError): - logger.debug("Android installer cancellation scheduling failed", exc_info=True) +def _update_install_error_message(status: str) -> str: + messages = { + "invalid_apk": "Không đọc được gói cập nhật. Hãy tải lại.", + "package_mismatch": "Gói cập nhật không dành cho ứng dụng này.", + "release_version_mismatch": "Phiên bản gói cập nhật không hợp lệ.", + "installed_signer_mismatch": "Chữ ký bản cập nhật không khớp ứng dụng đang cài.", + "manifest_signer_mismatch": "Chữ ký gói cập nhật không hợp lệ.", + "apk_signature_invalid": "Không xác minh được chữ ký gói cập nhật.", + "release_metadata_invalid": "Thông tin bản cập nhật không hợp lệ.", + "archive_identity_unavailable": "Không đọc được định danh gói cập nhật. Hãy thử lại.", + "source_unavailable": "Không tìm thấy gói đã tải. Hãy tải lại.", + "verification_timeout": "Xác minh bản cập nhật quá lâu. Hãy thử lại.", + "package_staging_failed": "Không chuẩn bị được gói cài đặt. Hãy kiểm tra bộ nhớ rồi thử lại.", + "update_verification_failed": "Không xác minh được gói cập nhật. Hãy thử lại.", + "sha256_mismatch": "Gói cập nhật bị hỏng. Hãy tải lại.", + "size_mismatch": "Gói cập nhật chưa đầy đủ. Hãy tải lại.", + "already_installed": "Bản cập nhật này đã được cài đặt.", + "package_installer_unavailable": "Không tìm thấy trình cài đặt trên thiết bị.", + "permission_settings_unavailable": "Không mở được quyền cài ứng dụng. Hãy kiểm tra Cài đặt Android.", + } + return messages.get(status, "Không mở được trình cài đặt. Hãy thử lại.") class AppController: @@ -395,6 +340,7 @@ async def guarded_task(): def _create_update_coordinator(self) -> UpdateCoordinator: target = detect_runtime_target() + downloader = VerifiedDownloader() if target.platform == "windows": from platform_utils.windows_update import ( WindowsPackageLauncher, @@ -405,18 +351,22 @@ def _create_update_coordinator(self) -> UpdateCoordinator: launcher = WindowsPackageLauncher() elif target.platform == "android": verifier = DownloadedPackageVerifier() - launcher = _AndroidPackageLauncher( + launcher = AndroidPackageLauncher( + self._android_background, + self._safe_run_task, + lambda: self._update_candidate.manifest.release_version, + ) + downloader = AndroidPackageDownloader( self._android_background, self._safe_run_task, lambda: self._update_candidate.manifest.release_version, - self._on_android_update_result, ) else: verifier = DownloadedPackageVerifier() launcher = _UnavailablePackageLauncher() return UpdateCoordinator( GitHubReleaseClient(), - VerifiedDownloader(), + downloader, verifier, launcher, target, @@ -438,33 +388,6 @@ def _check_updates_now(self) -> None: self._manual_update_check_requested = True self._update_coordinator.check_now() - def _on_android_update_result(self, result: dict) -> None: - status = result.get("status") - if status == "installer_opened": - self._diag_breadcrumb("update_install", "succeeded", "android") - self._update_banner.visible = False - self.page.update() - elif status == "permission_required": - self._update_banner.visible = True - self._update_btn.disabled = False - self._update_btn.content = "Mở trình cài đặt" - self._show_snackbar( - "Hãy cho phép cài ứng dụng rồi nhấn Cập nhật lại", - ft.Icons.SECURITY_ROUNDED, - C.WARNING, - ) - self.page.update() - else: - self._update_banner.visible = True - self._update_btn.disabled = False - self._update_btn.content = "Thử lại" - self._show_snackbar( - "Android đã từ chối gói cập nhật", - ft.Icons.ERROR_OUTLINE_ROUNDED, - C.CRITICAL, - ) - self.page.update() - def _submission_workflow_factory(self, client): """Build a fresh workflow only for the orchestrator's current client.""" if client is not getattr(self.orchestrator, "client", None): @@ -800,9 +723,6 @@ def _run_dashboard_header_action(self, handler) -> None: self._safe_run_task(handler) def _init_banner_and_states(self): - self._update_icon = ft.Icon( - ft.Icons.SYSTEM_UPDATE_ROUNDED, size=16, color=C.UPDATE - ) self._update_text = ft.Text( "Có phiên bản mới!", size=12, @@ -812,7 +732,6 @@ def _init_banner_and_states(self): ) self._update_progress = ft.ProgressBar( value=0, - width=0, height=3, color=C.UPDATE, bgcolor=C.BORDER, @@ -820,13 +739,21 @@ def _init_banner_and_states(self): ) self._update_btn = ft.TextButton( "Cập nhật", - icon=ft.Icons.DOWNLOAD_ROUNDED, - style=ft.ButtonStyle(color=C.UPDATE), + style=ft.ButtonStyle( + color=C.UPDATE, shape=ft.RoundedRectangleBorder(radius=8), + ), on_click=self._request_update_download, ) + self._update_cancel_btn = ft.TextButton( + "Hủy tải", visible=False, + style=ft.ButtonStyle( + color=C.TEXT_SECONDARY, shape=ft.RoundedRectangleBorder(radius=8), + ), + on_click=lambda _event: self._update_coordinator.cancel_download(), + ) self._update_banner = ft.Container( content=ft.Column([ - ft.Row([self._update_icon, self._update_text, self._update_btn], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER), + ft.Row([self._update_text, self._update_btn, self._update_cancel_btn], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER), self._update_progress, ], spacing=2, tight=True), bgcolor=C.SURFACE_ELEVATED, @@ -1007,6 +934,8 @@ def _init_views_and_transitions(self): on_test_mail=self._on_test_mail, on_theme_preview=self._rebuild_colors, on_check_update=self._check_updates_now, + on_update_action=self._request_update_download, + on_cancel_update=lambda: self._update_coordinator.cancel_download(), ) self.grade_overview_view = GradeOverviewView( on_close=lambda: self._safe_run_task(self._close_grades), @@ -2462,11 +2391,15 @@ def _rebuild_colors(self): # Update banner is a semantic elevated surface, not a fixed brown card. self._update_banner.bgcolor = _C.SURFACE_ELEVATED self._update_banner.border = ft.Border.all(1, _C.UPDATE) - self._update_icon.color = _C.UPDATE self._update_text.color = _C.UPDATE self._update_progress.color = _C.UPDATE self._update_progress.bgcolor = _C.BORDER - self._update_btn.style = ft.ButtonStyle(color=_C.UPDATE) + self._update_btn.style = ft.ButtonStyle( + color=_C.UPDATE, shape=ft.RoundedRectangleBorder(radius=8), + ) + self._update_cancel_btn.style = ft.ButtonStyle( + color=_C.TEXT_SECONDARY, shape=ft.RoundedRectangleBorder(radius=8), + ) # Settings view bgcolor self.settings_view.bgcolor = _C.BG @@ -2625,24 +2558,47 @@ def _on_test_mail(self, mock_type="critical"): EmailNotifier().notify([dummy]) def _on_update_event(self, event: UpdateEvent) -> None: + if event.candidate is not None: + if event.candidate != self._update_candidate: + self._update_ready = False + self._update_candidate = event.candidate self._safe_run_task(self._apply_update_event, event) def _request_update_download(self, _event=None) -> None: candidate = getattr(self, "_update_candidate", None) if candidate is not None: - self._update_coordinator.request_download(candidate) + if getattr(self, "_update_open_release_page", False): + self._open_external_update_url(candidate.manifest.release_notes_url) + elif getattr(self, "_update_ready", False): + self._show_update_confirmation() + else: + self._update_coordinator.request_download(candidate) async def _apply_update_event(self, event: UpdateEvent) -> None: candidate = event.candidate if candidate is not None: self._update_candidate = candidate + if event.kind is not UpdateEventKind.INSTALL_FAILED: + self._update_open_release_page = False check_button = getattr(self.settings_view, "_check_update_btn", None) + cancel_button = getattr(self, "_update_cancel_btn", None) + self._update_btn.visible = event.kind not in { + UpdateEventKind.DOWNLOADING, UpdateEventKind.DOWNLOAD_PROGRESS, + UpdateEventKind.VERIFYING, UpdateEventKind.INSTALLING, + } + if cancel_button is not None: + cancel_button.visible = event.kind in { + UpdateEventKind.DOWNLOADING, UpdateEventKind.DOWNLOAD_PROGRESS, + } if event.kind is UpdateEventKind.CHECKING: self._diag_breadcrumb("update_check", "started", "github") if check_button is not None: check_button.disabled = True check_button.content = "Đang kiểm tra..." elif event.kind is UpdateEventKind.UP_TO_DATE: + self._update_ready = False + self._update_candidate = None + self._update_banner.visible = False self._diag_breadcrumb("update_check", "succeeded", "github") if check_button is not None: check_button.disabled = False @@ -2660,26 +2616,29 @@ async def _apply_update_event(self, event: UpdateEvent) -> None: if check_button is not None: check_button.disabled = False check_button.content = "Kiểm tra ngay" - self._update_icon.name = ft.Icons.SYSTEM_UPDATE_ROUNDED - self._update_icon.color = C.UPDATE self._update_text.value = ( - f"Phiên bản mới v{candidate.manifest.release_version} đã sẵn sàng!" + f"Có bản cập nhật v{candidate.manifest.release_version}" ) self._update_btn.visible = True self._update_btn.disabled = False self._update_btn.content = "Cập nhật" - self._update_btn.icon = ft.Icons.DOWNLOAD_ROUNDED + self._update_btn.icon = None self._update_progress.visible = False self._update_banner.visible = True + if getattr(self, "_update_ready", False): + self._update_text.value = "Đã tải xong bản cập nhật" + self._update_btn.content = "Cài đặt" elif event.kind in {UpdateEventKind.DOWNLOADING, UpdateEventKind.DOWNLOAD_PROGRESS}: if event.kind is UpdateEventKind.DOWNLOADING: self._diag_breadcrumb("update_download", "started", "github") if candidate is not None: self._update_text.value = ( - f"Đang tải và xác minh v{candidate.manifest.release_version}..." + f"Đang tải v{candidate.manifest.release_version} · {int((event.progress or 0) * 100)}%" ) self._update_btn.disabled = True - self._update_btn.content = "Đang tải và xác minh..." + self._update_ready = False + self._update_btn.content = "Đang tải" + self._update_banner.visible = True self._update_progress.visible = True self._update_progress.value = event.progress or 0.0 elif event.kind in { @@ -2688,9 +2647,44 @@ async def _apply_update_event(self, event: UpdateEvent) -> None: }: self._diag_breadcrumb("update_download", "succeeded", "github") self._update_btn.disabled = False - self._update_btn.content = "Cập nhật" + self._update_ready = True + self._update_btn.content = "Cài đặt" if event.kind is UpdateEventKind.READY_TO_INSTALL else "Mở trang tải" + self._update_text.value = "Đã tải xong bản cập nhật" if event.kind is UpdateEventKind.READY_TO_INSTALL else "Có bản cập nhật" + self._update_progress.visible = False + self._update_banner.visible = True + elif event.kind is UpdateEventKind.VERIFYING: + self._update_text.value = "Đang xác minh bản cập nhật…" + self._update_btn.content = "Đang xác minh" + self._update_btn.disabled = True + self._update_progress.visible = True + self._update_progress.value = None + elif event.kind is UpdateEventKind.INSTALLING: + self._diag_breadcrumb("update_install", "started", "os") + self._update_text.value = "Đang mở trình cài đặt…" + self._update_btn.content = "Đang mở" + self._update_btn.disabled = True + elif event.kind is UpdateEventKind.INSTALL_PERMISSION_REQUIRED: + self._update_ready = True + self._update_banner.visible = True + self._update_text.value = "Cho phép cài ứng dụng, rồi quay lại và nhấn Cài đặt." + self._update_btn.content = "Cài đặt" + self._update_btn.disabled = False + self._update_progress.visible = False + elif event.kind is UpdateEventKind.INSTALL_FAILED: + self._diag_breadcrumb("update_install", "failed", "os") + self._update_ready = True + self._update_banner.visible = True + self._update_text.value = _update_install_error_message(event.message) + self._update_btn.content = "Thử lại" + self._update_open_release_page = event.message in { + "installed_signer_mismatch", "manifest_signer_mismatch", + "apk_signature_invalid", "release_metadata_invalid", + "package_mismatch", "release_version_mismatch", "invalid_apk", + } + if self._update_open_release_page: + self._update_btn.content = "Bản phát hành" + self._update_btn.disabled = False self._update_progress.visible = False - self._show_update_confirmation() elif event.kind is UpdateEventKind.FAILED: if candidate is None: self._diag_breadcrumb("update_check", "failed", "github") @@ -2707,6 +2701,7 @@ async def _apply_update_event(self, event: UpdateEvent) -> None: C.CRITICAL, ) else: + self._update_ready = False self._diag_breadcrumb("update_download", "failed", "github") should_notify = self._update_banner.visible self._manual_update_check_requested = False @@ -2720,6 +2715,10 @@ async def _apply_update_event(self, event: UpdateEvent) -> None: "Không thể tải hoặc xác minh " f"v{candidate.manifest.release_version}. Hãy thử lại." ) + if "insufficient_space" in event.message: + self._update_text.value = "Không đủ dung lượng. Hãy giải phóng bộ nhớ rồi thử lại." + elif "storage_unavailable" in event.message: + self._update_text.value = "Không truy cập được bộ nhớ tải về. Hãy thử lại." if should_notify: self._show_snackbar( "Không thể tải hoặc xác minh bản cập nhật", @@ -2727,20 +2726,32 @@ async def _apply_update_event(self, event: UpdateEvent) -> None: C.CRITICAL, ) elif event.kind is UpdateEventKind.CANCELLED: + self._update_ready = False + self._update_text.value = "Đã hủy tải bản cập nhật" self._diag_breadcrumb("update_download", "cancelled", "user") self._update_btn.disabled = False self._update_btn.content = "Cập nhật" self._update_progress.visible = False elif event.kind is UpdateEventKind.INSTALL_LAUNCHED: - if platform_utils.IS_ANDROID: - self._diag_breadcrumb("update_install", "started", "android") - self._update_text.value = "Đang mở trình cài đặt Android..." - self._update_btn.content = "Đang mở..." - self._update_btn.disabled = True - self._update_banner.visible = True - else: - self._diag_breadcrumb("update_install", "succeeded", "os") - self._update_banner.visible = False + self._diag_breadcrumb("update_install", "succeeded", "os") + self._update_text.value = "Đã mở trình cài đặt" + self._update_btn.content = "Mở lại" + self._update_btn.disabled = False + self._update_banner.visible = platform_utils.IS_ANDROID + status_setter = getattr(self.settings_view, "set_update_status", None) + if callable(status_setter) and candidate is not None: + status_setter( + self._update_text.value, + progress=self._update_progress.value, + show_progress=self._update_progress.visible, + action_label=self._update_btn.content if not self._update_btn.disabled else None, + can_cancel=bool(cancel_button and cancel_button.visible), + ) + elif callable(status_setter): + if event.kind is UpdateEventKind.UP_TO_DATE: + status_setter("Bạn đang dùng phiên bản mới nhất") + elif event.kind is UpdateEventKind.FAILED: + status_setter("Không thể kiểm tra cập nhật. Hãy kiểm tra mạng và thử lại.") self.page.update() if event.kind is UpdateEventKind.INSTALL_LAUNCHED and platform_utils.IS_WINDOWS: await self.page.window.destroy() @@ -2748,10 +2759,17 @@ async def _apply_update_event(self, event: UpdateEvent) -> None: def _show_update_confirmation(self) -> None: if platform_utils.IS_WINDOWS: affirmative = "Cài đặt và thoát" + content = "Ứng dụng sẽ đóng để cài bản cập nhật." elif platform_utils.IS_ANDROID: affirmative = "Mở trình cài đặt" + content = "Mở trình cài đặt Android cho bản cập nhật đã tải?" else: affirmative = "Mở trang tải IPA" + content = ( + "Tải IPA trên máy tính, sau đó ký lại bằng cùng Apple ID và bundle ID " + "qua Sideloadly/AltStore. Chứng thực cá nhân có thể cần được làm mới " + "định kỳ. UTHelper chỉ mở trang phát hành sau khi bạn xác nhận." + ) def cancel(_event): self.page.pop_dialog() @@ -2760,20 +2778,19 @@ def confirm(_event): self.page.pop_dialog() self._update_coordinator.confirm_install() - content = ( - "Tải IPA trên máy tính, sau đó ký lại bằng cùng Apple ID và bundle ID " - "qua Sideloadly/AltStore. Chứng thực cá nhân có thể cần được làm mới " - "định kỳ. UTHelper chỉ mở trang phát hành sau khi bạn xác nhận." - if not platform_utils.IS_WINDOWS and not platform_utils.IS_ANDROID - else "UTHelper sẽ chỉ mở trình cài đặt sau khi bạn xác nhận." - ) dialog = ft.AlertDialog( - modal=True, + modal=False, title=ft.Text("Xác nhận cập nhật"), content=ft.Text(content), actions=[ - ft.TextButton("Hủy", on_click=cancel), - ft.TextButton(affirmative, on_click=confirm), + ft.TextButton( + "Hủy", on_click=cancel, autofocus=True, + style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8)), + ), + ft.TextButton( + affirmative, on_click=confirm, + style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8)), + ), ], actions_alignment=ft.MainAxisAlignment.END, bgcolor=C.BG, diff --git a/src/gui/components/settings/system_section.py b/src/gui/components/settings/system_section.py index d0a4265..6ec01df 100644 --- a/src/gui/components/settings/system_section.py +++ b/src/gui/components/settings/system_section.py @@ -14,10 +14,10 @@ def init_system_controls(view): ) view._check_update_btn = ft.Button( "Kiểm tra ngay", - icon=ft.Icons.REFRESH_ROUNDED, on_click=getattr(view, "_handle_check_update", None), bgcolor=C.SURFACE, color=C.ACCENT, + style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8)), ) # Thiết lập riêng trên Desktop (Windows) view._sw_start_with_windows = ft.Switch( @@ -113,7 +113,11 @@ def build_system_section(view) -> ft.Control: view._make_themed_label("Cập nhật"), view._sw_auto_update, ft.Row([view._check_update_btn], alignment=ft.MainAxisAlignment.START), - view._build_hint("Chỉ kiểm tra phiên bản mới. Cài đặt và khởi động lại luôn cần xác nhận."), + ]) + update_status_group = getattr(view, "_update_status_group", None) + if update_status_group is not None: + controls.append(update_status_group) + controls.extend([ ft.Divider(height=16, color=C.BORDER), view._make_themed_label("Đồng bộ dữ liệu"), view._interval_field, diff --git a/src/gui/components/settings_view.py b/src/gui/components/settings_view.py index 5ea9067..c72c9c8 100644 --- a/src/gui/components/settings_view.py +++ b/src/gui/components/settings_view.py @@ -41,14 +41,14 @@ from gui.view_models.settings_form import SettingsFormSnapshot class SettingsView(ft.Container): - def __init__(self, page: ft.Page, orchestrator, on_close, on_saved=None, on_test_tray=None, on_test_mobile=None, on_test_tele=None, on_test_discord=None, on_test_mail=None, on_theme_preview=None, autostart_coordinator=None, on_check_update=None): + def __init__(self, page: ft.Page, orchestrator, on_close, on_saved=None, on_test_tray=None, on_test_mobile=None, on_test_tele=None, on_test_discord=None, on_test_mail=None, on_theme_preview=None, autostart_coordinator=None, on_check_update=None, on_update_action=None, on_cancel_update=None): super().__init__() - self._init_variables(page, orchestrator, on_close, on_saved, on_test_tray, on_test_mobile, on_test_tele, on_test_discord, on_test_mail, on_theme_preview, autostart_coordinator, on_check_update) + self._init_variables(page, orchestrator, on_close, on_saved, on_test_tray, on_test_mobile, on_test_tele, on_test_discord, on_test_mail, on_theme_preview, autostart_coordinator, on_check_update, on_update_action, on_cancel_update) self._init_controls() self._init_layout() self._rendered_palette = semantic_palette() - def _init_variables(self, page, orchestrator, on_close, on_saved, on_test_tray, on_test_mobile, on_test_tele, on_test_discord, on_test_mail, on_theme_preview, autostart_coordinator, on_check_update): + def _init_variables(self, page, orchestrator, on_close, on_saved, on_test_tray, on_test_mobile, on_test_tele, on_test_discord, on_test_mail, on_theme_preview, autostart_coordinator, on_check_update, on_update_action, on_cancel_update): self._page = page self._orchestrator = orchestrator self._on_close_cb = on_close @@ -60,6 +60,8 @@ def _init_variables(self, page, orchestrator, on_close, on_saved, on_test_tray, self._on_test_mail = on_test_mail self._on_theme_preview = on_theme_preview self._on_check_update = on_check_update + self._on_update_action = on_update_action + self._on_cancel_update = on_cancel_update self.visible = False self.expand = True self.bgcolor = C.BG @@ -154,6 +156,45 @@ def _init_controls(self): init_account_controls(self) init_display_controls(self) init_system_controls(self) + self._update_status_text = ft.Text( + "", + size=11, + color=C.TEXT_SECONDARY, + visible=False, + ) + self._update_progress = ft.ProgressBar( + value=0, + width=260, + height=3, + color=C.UPDATE, + bgcolor=C.BORDER, + visible=False, + ) + self._update_action_btn = ft.Button( + "", + visible=False, + bgcolor=C.SURFACE, + color=C.ACCENT, + style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8)), + on_click=self._handle_update_action, + ) + self._update_cancel_btn = ft.TextButton( + "Hủy tải", + visible=False, + style=ft.ButtonStyle(color=C.TEXT_SECONDARY), + on_click=self._handle_cancel_update, + ) + self._update_action_row = ft.Row( + [self._update_action_btn, self._update_cancel_btn], + spacing=8, + visible=False, + ) + self._update_status_group = ft.Column( + [self._update_status_text, self._update_progress, self._update_action_row], + spacing=4, + tight=True, + visible=False, + ) init_privacy_controls(self) init_urgency_controls(self) init_theme_controls(self) @@ -485,6 +526,46 @@ def _navigate_to_detail(self, cat_id: str): self._update_save_button_state() self.update() + def _handle_update_action(self, _event=None) -> None: + if self._on_update_action: + self._on_update_action() + + def _handle_cancel_update(self, _event=None) -> None: + if self._on_cancel_update: + self._on_cancel_update() + + def set_update_status( + self, + message: str, + *, + progress: float | None = None, + show_progress: bool = False, + action_label: str | None = None, + can_cancel: bool = False, + color=None, + ) -> None: + """Show update progress and actions while the settings page is open.""" + self._update_status_text.value = message + self._update_status_text.visible = bool(message) + self._update_status_text.color = color or C.TEXT_SECONDARY + self._update_progress.visible = bool(show_progress) + self._update_progress.value = ( + min(1.0, max(0.0, progress)) if progress is not None else None + ) + self._update_action_btn.content = action_label or "" + self._update_action_btn.visible = bool(action_label) + self._update_cancel_btn.visible = bool(can_cancel) + self._update_action_row.visible = bool(action_label or can_cancel) + self._update_status_group.visible = bool( + message or show_progress or action_label or can_cancel + ) + try: + self.update() + except Exception: + logging.getLogger(__name__).debug( + "Update status UI refresh skipped", exc_info=True + ) + def _navigate_to_hub(self): self._active_category_id = None self._current_screen = "hub" diff --git a/src/platform_utils/android_updates.py b/src/platform_utils/android_updates.py new file mode 100644 index 0000000..334bc36 --- /dev/null +++ b/src/platform_utils/android_updates.py @@ -0,0 +1,203 @@ +"""Bounded coordinator adapters for Android's native update services. + +Only the coordinator worker waits here; bridge coroutines run on the page loop. +DownloadManager owns transfers, so closing the page detaches the observer while +an explicit user cancellation removes the native download. +""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import CancelledError, TimeoutError as FutureTimeoutError +import logging +from pathlib import Path +import threading +import time + +from packaging.version import InvalidVersion, Version + +from core.update_checker import DownloadCancelled, PackageIntegrityError +from core.update_models import LaunchResult, ReleasePackage + + +PACKAGE_ID = "com.uthelper.uthelper" +logger = logging.getLogger(__name__) + + +def android_release_build_number(version: str) -> int: + """Use the same strict X.Y.Z build number as the release pipeline.""" + try: + parsed = Version(version) + except InvalidVersion as exc: + raise ValueError("invalid Android release version") from exc + if ( + str(parsed) != version + or len(parsed.release) != 3 + or parsed.pre + or parsed.post + or parsed.dev + or parsed.local + or any(part < 0 or part > 999 for part in parsed.release) + ): + raise ValueError("Android release version must be numeric X.Y.Z") + major, minor, patch = parsed.release + return major * 1_000_000 + minor * 1_000 + patch + + +class NativeUpdateClient: + """Wait for a native response, never treating queued work as success.""" + + def __init__(self, bridge, task_runner, release_version_provider): + self.bridge = bridge + self.task_runner = task_runner + self.release_version_provider = release_version_provider + self.stopped = threading.Event() + + def identity(self, package: ReleasePackage) -> tuple: + if package.platform != "android" or package.package_type != "apk" or self.bridge is None or not self.bridge.available: + raise RuntimeError("Android update service is unavailable") + return ( + package.url, + package.sha256, + package.size, + PACKAGE_ID, + android_release_build_number(self.release_version_provider()), + package.certificate_fingerprint, + ) + + def call(self, method, *args, timeout: float = 30.0): + if self.stopped.is_set(): + raise DownloadCancelled("Android update observer stopped") + + async def invoke(): + if self.stopped.is_set(): + raise DownloadCancelled("Android update observer stopped") + return await asyncio.wait_for(method(*args), timeout=timeout) + + future = self.task_runner(invoke) + if future is None: + raise RuntimeError("Android update session is unavailable") + deadline = time.monotonic() + timeout + 1 + try: + while True: + if self.stopped.is_set(): + raise DownloadCancelled("Android update observer stopped") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Android update service did not respond") + try: + result = future.result(timeout=min(0.2, remaining)) + break + except FutureTimeoutError: + if future.done(): + raise + except (TimeoutError, CancelledError, DownloadCancelled): + future.cancel() + raise + if not isinstance(result, dict) or not isinstance(result.get("status"), str): + raise RuntimeError("Android update service returned an invalid response") + return result + + +class AndroidPackageLauncher: + def __init__(self, bridge, task_runner, release_version_provider): + self._native = NativeUpdateClient(bridge, task_runner, release_version_provider) + + def launch(self, path: Path, package: ReleasePackage) -> LaunchResult: + try: + _, sha256, size, package_id, version_code, signer = self._native.identity(package) + result = self._native.call( + self._native.bridge.install_update, + str(path), + sha256, + size, + package_id, + version_code, + signer, + timeout=190.0, + ) + status = result["status"] + reason = result.get("reason") or status + if status != "installer_opened": + logger.warning( + "Android update handoff: status=%s stage=%s reason=%s", + status, + result.get("stage", "unknown"), + reason, + ) + return LaunchResult(status == "installer_opened", reason, status=status) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + logger.warning("Android update bridge failed: %s", type(exc).__name__) + return LaunchResult(False, "Android installer service failed", status="bridge_failed") + + def cancel(self) -> None: + self._native.stopped.set() + bridge = self._native.bridge + if bridge is not None and bridge.available: + self._native.task_runner(bridge.cancel_update) + + +class AndroidPackageDownloader: + """Observe one persisted OS job without owning its process lifetime.""" + + def __init__( + self, + bridge, + task_runner, + release_version_provider, + *, + observation_timeout_seconds: float = 1800.0, + poll_interval_seconds: float = 0.5, + ): + if not 0 < observation_timeout_seconds <= 1800: + raise ValueError("Android download observation must be within 30 minutes") + if not 0 < poll_interval_seconds <= 5: + raise ValueError("Android download polling interval is invalid") + self._native = NativeUpdateClient(bridge, task_runner, release_version_provider) + self._detached = threading.Event() + self._timeout = observation_timeout_seconds + self._poll_interval = poll_interval_seconds + + def detach(self) -> None: + self._detached.set() + self._native.stopped.set() + + def has_pending_download(self, package: ReleasePackage) -> bool: + identity = self._native.identity(package) + result = self._native.call(self._native.bridge.get_update_download_status, *identity) + return result["status"] in {"pending", "running", "paused", "completed"} + + def download(self, package: ReleasePackage, *, cancel: threading.Event, progress=None) -> Path: + identity = self._native.identity(package) + if self._detached.is_set(): + raise DownloadCancelled("download observer detached") + if cancel.is_set(): + raise DownloadCancelled("download cancelled") + result = self._native.call(self._native.bridge.start_update_download, *identity) + started = time.monotonic() + while True: + if self._detached.is_set(): + raise DownloadCancelled("download observer detached") + if cancel.is_set(): + self._native.call(self._native.bridge.cancel_update_download, *identity) + raise DownloadCancelled("download cancelled") + status = result["status"] + if status == "completed": + path = Path(result.get("local_path", "")) + if not path.is_absolute() or path.suffix.lower() != ".apk": + raise PackageIntegrityError("Android download path is invalid") + if progress is not None: + progress(package.size, package.size) + return path + if status not in {"pending", "running", "paused"}: + raise OSError("Android download failed: " + str(result.get("reason", status))) + done = max(0, min(package.size, int(result.get("downloaded", 0)))) + if progress is not None: + progress(done, package.size) + if time.monotonic() - started >= self._timeout: + # Leave the OS transfer recoverable on the next request/restart. + raise TimeoutError("Android download is still pending; retry to reconnect") + self._detached.wait(self._poll_interval) + if self._detached.is_set() or cancel.is_set(): + continue + result = self._native.call(self._native.bridge.get_update_download_status, *identity) diff --git a/src/platform_utils/background_sync.py b/src/platform_utils/background_sync.py index 1a893b5..4aea17f 100644 --- a/src/platform_utils/background_sync.py +++ b/src/platform_utils/background_sync.py @@ -124,6 +124,22 @@ async def cancel_update(self) -> None: if self.service: await self.service.cancel_update() + async def start_update_download(self, *identity) -> dict[str, Any]: + if not self.service: + return {"status": "unavailable"} + return await self.service.start_update_download(*identity) + + async def get_update_download_status(self, *identity) -> dict[str, Any]: + if not self.service: + return {"status": "unavailable"} + return await self.service.get_update_download_status(*identity) + + async def cancel_update_download(self, *identity) -> dict[str, Any]: + if not self.service: + return {"status": "unavailable"} + await self.service.cancel_update_download(*identity) + return {"status": "cancelled"} + async def show_notification( self, *, diff --git a/tests/test_android_updates.py b/tests/test_android_updates.py new file mode 100644 index 0000000..7117b76 --- /dev/null +++ b/tests/test_android_updates.py @@ -0,0 +1,209 @@ +import asyncio +from concurrent.futures import Future +from pathlib import Path +import threading +from types import SimpleNamespace + +import pytest + +from core.update_checker import DownloadCancelled, PackageIntegrityError +from core.update_models import ReleasePackage +from platform_utils.android_updates import AndroidPackageDownloader, AndroidPackageLauncher + + +@pytest.fixture +def package(): + return ReleasePackage( + "android", + "arm64", + "apk", + "sideload", + "https://github.com/Chouwzi/UTHelper/releases/download/v2.5.1/UTHelper.apk", + "a" * 64, + 100, + "com.uthelper.uthelper", + "b" * 64, + {"kind": "android_package_installer"}, + ) + + +def run_task(handler, *args): + future = Future() + try: + future.set_result(asyncio.run(handler(*args))) + except Exception as exc: + future.set_exception(exc) + return future + + +@pytest.mark.parametrize( + "status,acknowledged", + [ + ("installer_opened", True), + ("permission_required", False), + ("signer_mismatch", False), + ("bridge_failed", False), + ], +) +def test_launcher_only_acknowledges_native_installer_opened(package, status, acknowledged): + calls = [] + + async def install(*args): + calls.append(args) + return {"status": status} + + launcher = AndroidPackageLauncher( + SimpleNamespace(available=True, install_update=install), + run_task, + lambda: "2.5.1", + ) + result = launcher.launch(Path("UTHelper.apk"), package) + assert result.acknowledged is acknowledged + assert result.status == status + assert calls == [("UTHelper.apk", package.sha256, 100, "com.uthelper.uthelper", 2_005_001, package.certificate_fingerprint)] + + +def test_scheduling_is_not_installation_acknowledgement(package): + response = Future() + scheduled = threading.Event() + results = [] + + def schedule(_handler): + scheduled.set() + return response + + bridge = SimpleNamespace(available=True, install_update=lambda: None) + launcher = AndroidPackageLauncher(bridge, schedule, lambda: "2.5.1") + thread = threading.Thread(target=lambda: results.append(launcher.launch(Path("app.apk"), package))) + thread.start() + try: + assert scheduled.wait(1) + assert results == [] + response.set_result({"status": "permission_required"}) + thread.join(1) + assert not thread.is_alive() + assert results[0].acknowledged is False + finally: + if not response.done(): + response.set_result({"status": "bridge_failed"}) + thread.join(1) + + +def test_shutdown_before_scheduled_coroutine_does_not_dispatch_installer(package): + response = Future() + scheduled = threading.Event() + handlers = [] + calls = [] + results = [] + + async def install(*_args): + calls.append("install") + return {"status": "installer_opened"} + + async def cancel(): + calls.append("cancel") + + def schedule(handler): + if handler is cancel: + return run_task(handler) + handlers.append(handler) + scheduled.set() + return response + + bridge = SimpleNamespace(available=True, install_update=install, cancel_update=cancel) + launcher = AndroidPackageLauncher(bridge, schedule, lambda: "2.5.1") + thread = threading.Thread(target=lambda: results.append(launcher.launch(Path("app.apk"), package))) + thread.start() + try: + assert scheduled.wait(1) + launcher.cancel() + with pytest.raises(DownloadCancelled): + asyncio.run(handlers[0]()) + thread.join(1) + assert not thread.is_alive() + assert results[0].acknowledged is False + assert calls == ["cancel"] + finally: + response.cancel() + thread.join(1) + + +class DownloadBridge: + available = True + + def __init__(self, states): + self.states = iter(states) + self.calls = [] + + async def start_update_download(self, *identity): + self.calls.append(("start", identity)) + return next(self.states) + + async def get_update_download_status(self, *identity): + self.calls.append(("query", identity)) + return next(self.states) + + async def cancel_update_download(self, *identity): + self.calls.append(("cancel", identity)) + return {"status": "missing"} + + +def downloader(bridge, **kwargs): + return AndroidPackageDownloader(bridge, run_task, lambda: "2.5.1", poll_interval_seconds=0.001, **kwargs) + + +def test_native_transfer_progress_and_complete_local_handoff(package, tmp_path): + path = tmp_path / "app.apk" + bridge = DownloadBridge( + [ + {"status": "running", "downloaded": 30}, + {"status": "paused", "downloaded": 50}, + {"status": "completed", "local_path": str(path)}, + ] + ) + progress = [] + assert downloader(bridge).download(package, cancel=threading.Event(), progress=lambda *v: progress.append(v)) == path + assert progress == [(30, 100), (50, 100), (100, 100)] + assert [name for name, _ in bridge.calls] == ["start", "query", "query"] + assert all(args == bridge.calls[0][1] for _, args in bridge.calls) + + +def test_recovery_only_queries_previously_authorized_job(package): + bridge = DownloadBridge([{"status": "missing"}, {"status": "completed"}]) + observer = downloader(bridge) + assert observer.has_pending_download(package) is False + assert observer.has_pending_download(package) is True + assert [name for name, _ in bridge.calls] == ["query", "query"] + + +def test_shutdown_detaches_without_removing_os_download(package): + bridge = DownloadBridge([{"status": "running"}]) + observer = downloader(bridge) + + def progress(*_args): + observer.detach() + + with pytest.raises(DownloadCancelled): + observer.download(package, cancel=threading.Event(), progress=progress) + assert "cancel" not in [name for name, _ in bridge.calls] + + +def test_user_cancellation_removes_native_job(package): + bridge = DownloadBridge([{"status": "running"}, {"status": "running"}]) + cancel = threading.Event() + with pytest.raises(DownloadCancelled): + downloader(bridge).download(package, cancel=cancel, progress=lambda *_args: cancel.set()) + assert [name for name, _ in bridge.calls] == ["start", "cancel"] + + +def test_observer_timeout_keeps_job_recoverable(package): + bridge = DownloadBridge([{"status": "paused"}]) + with pytest.raises(TimeoutError): + downloader(bridge, observation_timeout_seconds=0.000001).download(package, cancel=threading.Event()) + assert [name for name, _ in bridge.calls] == ["start"] + + +def test_rejects_unexpected_completion_path(package): + bridge = DownloadBridge([{"status": "completed", "local_path": "relative.apk"}]) + with pytest.raises(PackageIntegrityError): + downloader(bridge).download(package, cancel=threading.Event()) diff --git a/tests/test_background_sync_bridge.py b/tests/test_background_sync_bridge.py index f63b95d..c2cccf6 100644 --- a/tests/test_background_sync_bridge.py +++ b/tests/test_background_sync_bridge.py @@ -147,6 +147,32 @@ async def cancel_update(self): ] +def test_durable_update_download_bridge_preserves_pinned_identity_and_void_cancel(): + calls = [] + + class Service: + async def start_update_download(self, *identity): + calls.append(("start", identity)) + return {"status": "running"} + + async def get_update_download_status(self, *identity): + calls.append(("query", identity)) + return {"status": "completed", "local_path": "/private/update.apk"} + + async def cancel_update_download(self, *identity): + calls.append(("cancel", identity)) + return None # The native command deliberately has no response body. + + bridge = AndroidBackgroundBridge.__new__(AndroidBackgroundBridge) + bridge.service = Service() + identity = ("https://github.com/Chouwzi/UTHelper/releases/download/v2.5.1/app.apk", + "a" * 64, 100, "com.uthelper.uthelper", 2_005_001, "b" * 64) + assert asyncio.run(bridge.start_update_download(*identity)) == {"status": "running"} + assert asyncio.run(bridge.get_update_download_status(*identity))["status"] == "completed" + assert asyncio.run(bridge.cancel_update_download(*identity)) == {"status": "cancelled"} + assert calls == [("start", identity), ("query", identity), ("cancel", identity)] + + @pytest.mark.parametrize( ("base_url", "client_origin", "token_origin", "expected"), ( diff --git a/tests/test_gui_app_controller.py b/tests/test_gui_app_controller.py index 95d5cad..e816231 100644 --- a/tests/test_gui_app_controller.py +++ b/tests/test_gui_app_controller.py @@ -16,8 +16,10 @@ from core.update_models import ReleasePackage from gui.app_controller import ( AppController, - _AndroidPackageLauncher, - _android_release_build_number, +) +from platform_utils.android_updates import ( + AndroidPackageLauncher as _AndroidPackageLauncher, + android_release_build_number as _android_release_build_number, ) from gui.components.activity_card import ActivityCard from gui.components.today_schedule_panel import TodaySchedulePanel @@ -480,8 +482,10 @@ async def cancel_update(self): pass def run_task(handler, *args): - asyncio.run(handler(*args)) - return object() + from concurrent.futures import Future + result = Future() + result.set_result(asyncio.run(handler(*args))) + return result package = ReleasePackage( platform="android", @@ -521,6 +525,7 @@ def test_update_confirmation_uses_required_windows_affirmative_copy(monkeypatch) controller._show_update_confirmation() assert shown[0].actions[1].content == "Cài đặt và thoát" + assert shown[0].modal is False # Escape remains a safe dismissal action. def test_update_confirmation_explains_ios_sideload_and_resigning(monkeypatch): @@ -564,8 +569,8 @@ def test_update_download_replaces_stale_available_copy_with_progress_state(): ) ) - assert controller._update_text.value == "Đang tải và xác minh v2.3.0..." - assert controller._update_btn.content == "Đang tải và xác minh..." + assert controller._update_text.value == "Đang tải v2.3.0 · 0%" + assert controller._update_btn.content == "Đang tải" assert controller._update_btn.disabled is True assert controller._update_progress.visible is True @@ -615,18 +620,20 @@ def test_android_install_banner_waits_for_native_installer_confirmation(monkeypa asyncio.run( controller._apply_update_event( - UpdateEvent(UpdateEventKind.INSTALL_LAUNCHED, controller._update_candidate) + UpdateEvent(UpdateEventKind.INSTALLING, controller._update_candidate) ) ) assert controller._update_banner.visible is True - assert controller._update_text.value == "Đang mở trình cài đặt Android..." - assert controller._update_btn.content == "Đang mở..." + assert controller._update_text.value == "Đang mở trình cài đặt…" + assert controller._update_btn.content == "Đang mở" assert controller._update_btn.disabled is True - controller._on_android_update_result({"status": "installer_opened"}) - - assert controller._update_banner.visible is False + asyncio.run(controller._apply_update_event( + UpdateEvent(UpdateEventKind.INSTALL_LAUNCHED, controller._update_candidate) + )) + assert controller._update_text.value == "Đã mở trình cài đặt" + assert controller._update_btn.disabled is False def test_show_snackbar_uses_page_overlay_when_available(): @@ -646,3 +653,58 @@ def test_show_snackbar_uses_page_overlay_when_available(): assert isinstance(overlay_controls[0], ft.SnackBar) assert overlay_controls[0].open is True assert dialogs == [] + + +def test_download_ready_keeps_app_usable_until_explicit_install_click(): + controller = _update_event_controller() + dialogs = [] + controller._show_update_confirmation = lambda: dialogs.append("confirm") + asyncio.run(controller._apply_update_event( + UpdateEvent(UpdateEventKind.READY_TO_INSTALL, controller._update_candidate) + )) + assert dialogs == [] + assert controller._update_btn.content == "Cài đặt" + controller._request_update_download() + assert dialogs == ["confirm"] + + +def test_permission_retry_confirms_cached_package_without_downloading(): + controller = _update_event_controller() + dialogs = [] + controller._show_update_confirmation = lambda: dialogs.append("confirm") + # No downloader is installed: this action must go directly to confirmation. + asyncio.run(controller._apply_update_event( + UpdateEvent(UpdateEventKind.INSTALL_PERMISSION_REQUIRED, controller._update_candidate) + )) + assert "Cho phép cài" in controller._update_text.value + assert controller._update_btn.disabled is False + controller._request_update_download() + assert dialogs == ["confirm"] + + +def test_install_failure_is_distinct_from_download_failure(): + controller = _update_event_controller() + asyncio.run(controller._apply_update_event(UpdateEvent( + UpdateEventKind.INSTALL_FAILED, controller._update_candidate, + message="installed_signer_mismatch", + ))) + assert controller._update_text.value == "Chữ ký bản cập nhật không khớp ứng dụng đang cài." + assert controller._update_btn.disabled is False + controller._update_candidate.manifest.release_notes_url = "https://github.com/Chouwzi/UTHelper/releases/tag/v2.3.0" + opened = [] + controller._open_external_update_url = opened.append + assert controller._update_btn.content == "Bản phát hành" + controller._request_update_download() + assert opened == [controller._update_candidate.manifest.release_notes_url] + + +def test_download_state_remains_available_in_settings(): + controller = _update_event_controller() + statuses = [] + controller.settings_view.set_update_status = lambda *args, **kwargs: statuses.append((args, kwargs)) + asyncio.run(controller._apply_update_event(UpdateEvent( + UpdateEventKind.DOWNLOAD_PROGRESS, controller._update_candidate, progress=0.42, + ))) + assert statuses[0][0] == ("Đang tải v2.3.0 · 42%",) + assert statuses[0][1]["progress"] == 0.42 + assert statuses[0][1]["action_label"] is None diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index f05d64e..1b10d89 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -24,8 +24,10 @@ def test_project_version_is_only_authored_version(tmp_path): ) -def test_this_feature_release_bumps_the_single_authored_version(): - assert read_project_version(ROOT / "pyproject.toml") == "2.5.0" +def test_hotfix_version_can_upgrade_the_affected_release(): + version = read_project_version(ROOT / "pyproject.toml") + assert version == "2.5.1" + assert release_build_number(version) > release_build_number("2.5.0") def test_runtime_version_is_generated_from_the_single_authored_version(tmp_path): diff --git a/tests/test_settings_update_ui.py b/tests/test_settings_update_ui.py index 71fa156..f2672b9 100644 --- a/tests/test_settings_update_ui.py +++ b/tests/test_settings_update_ui.py @@ -1,5 +1,8 @@ from gui.components.settings.system_section import init_system_controls +from gui.components.settings.system_section import build_system_section from gui.components.settings_view import SettingsView +from types import SimpleNamespace +import flet as ft class _View: @@ -31,3 +34,64 @@ def test_manual_check_button_delegates_without_update_business_logic(): view._handle_check_update(None) assert calls == ["check"] + + +def test_update_action_and_cancel_delegate_to_coordinator_callbacks(): + calls = [] + view = SettingsView.__new__(SettingsView) + view._on_update_action = lambda: calls.append("action") + view._on_cancel_update = lambda: calls.append("cancel") + + view._handle_update_action(None) + view._handle_cancel_update(None) + + assert calls == ["action", "cancel"] + + +def test_update_status_displays_phase_progress_and_recovery_action(): + view = SettingsView.__new__(SettingsView) + view._update_status_text = SimpleNamespace(value="", visible=False, color=None) + view._update_progress = SimpleNamespace(value=0, visible=False) + view._update_action_btn = SimpleNamespace(content="", visible=False) + view._update_cancel_btn = SimpleNamespace(visible=False) + view._update_action_row = SimpleNamespace(visible=False) + view._update_status_group = SimpleNamespace(visible=False) + view.update = lambda: None + + view.set_update_status( + "Đang tải bản cập nhật…", + progress=0.4, + show_progress=True, + action_label="Cài đặt", + can_cancel=True, + ) + + assert view._update_status_text.value == "Đang tải bản cập nhật…" + assert view._update_progress.visible is True + assert view._update_progress.value == 0.4 + assert view._update_action_btn.content == "Cài đặt" + assert view._update_action_btn.visible is True + assert view._update_cancel_btn.visible is True + + view.set_update_status("Đã mở trình cài đặt.") + + assert view._update_status_group.visible is True + assert view._update_progress.visible is False + assert view._update_action_btn.visible is False + assert view._update_cancel_btn.visible is False + + +def test_system_settings_places_update_status_with_update_controls(): + view = _View() + init_system_controls(view) + view._make_themed_label = lambda label: ft.Text(label) + view._build_hint = lambda message: ft.Container(content=ft.Text(message)) + view._update_status_group = ft.Column([]) + view._interval_field = ft.Dropdown(options=[]) + view._fetch_months_field = ft.TextField() + + section = build_system_section(view) + + assert view._update_status_group in section.controls + status_index = section.controls.index(view._update_status_group) + assert view._check_update_btn in section.controls[status_index - 1].controls diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py index 23bdd2c..bc0ff30 100644 --- a/tests/test_update_checker.py +++ b/tests/test_update_checker.py @@ -1,5 +1,6 @@ import hashlib import io +import json import os import sys import threading @@ -296,6 +297,246 @@ def test_downloader_cancels_and_removes_partial_file(tmp_path, monkeypatch): assert list(tmp_path.glob("*.apk")) == [] +def test_downloader_uses_a_finite_slow_network_budget(tmp_path): + downloader = update_checker.VerifiedDownloader(cache_dir=tmp_path) + + assert downloader.total_timeout_seconds == 600 + assert update_checker.VerifiedDownloader( + cache_dir=tmp_path, + total_timeout_seconds=600, + ).total_timeout_seconds == 600 + with pytest.raises(ValueError, match="within 600 seconds"): + update_checker.VerifiedDownloader( + cache_dir=tmp_path, + total_timeout_seconds=601, + ) + + +def test_downloader_reuses_only_a_complete_verified_artifact(tmp_path, monkeypatch): + data = b"cached verified apk" + package = _release_package(data) + destination = tmp_path / "UTHelper-2.2.0.apk" + destination.write_bytes(data) + requests = [] + progress = [] + + def unexpected_request(*_args, **_kwargs): + requests.append("request") + return _Response(data) + + monkeypatch.setattr( + update_checker.urllib.request, + "urlopen", + unexpected_request, + ) + + result = update_checker.VerifiedDownloader(cache_dir=tmp_path).download( + package, + cancel=threading.Event(), + progress=lambda done, total: progress.append((done, total)), + ) + + assert result == destination + assert result.read_bytes() == data + assert requests == [] + assert progress[-1] == (len(data), len(data)) + + +@pytest.mark.parametrize("cached_data", [b"tampered package", b"too short"]) +def test_downloader_redownloads_cache_with_wrong_size_or_sha( + tmp_path, + monkeypatch, + cached_data, +): + data = b"expected package" + package = _release_package(data) + destination = tmp_path / "UTHelper-2.2.0.apk" + destination.write_bytes(cached_data) + requests = [] + + def open_request(request, timeout): + requests.append((request, timeout)) + return _Response(data) + + monkeypatch.setattr(update_checker.urllib.request, "urlopen", open_request) + + result = update_checker.VerifiedDownloader(cache_dir=tmp_path).download( + package, + cancel=threading.Event(), + ) + + assert result == destination + assert result.read_bytes() == data + assert len(requests) == 1 + + +def test_downloader_promotes_verified_complete_partial_without_request( + tmp_path, + monkeypatch, +): + data = b"complete interrupted transfer" + package = _release_package(data) + partial = tmp_path / "UTHelper-2.2.0.apk.part" + sidecar = partial.with_suffix(partial.suffix + ".json") + partial.write_bytes(data) + sidecar.write_text( + json.dumps( + { + "url": package.url, + "sha256": package.sha256, + "size": package.size, + } + ), + encoding="utf-8", + ) + requests = [] + progress = [] + monkeypatch.setattr( + update_checker.urllib.request, + "urlopen", + lambda *_args, **_kwargs: requests.append("request"), + ) + + destination = update_checker.VerifiedDownloader(cache_dir=tmp_path).download( + package, + cancel=threading.Event(), + progress=lambda done, total: progress.append((done, total)), + ) + + assert destination.read_bytes() == data + assert requests == [] + assert progress[-1] == (len(data), len(data)) + assert not partial.exists() + assert not sidecar.exists() + + +def test_downloader_rejects_tampered_complete_partial(tmp_path, monkeypatch): + data = b"complete interrupted transfer" + package = _release_package(data) + partial = tmp_path / "UTHelper-2.2.0.apk.part" + sidecar = partial.with_suffix(partial.suffix + ".json") + partial.write_bytes(b"x" * len(data)) + sidecar.write_text( + json.dumps( + { + "url": package.url, + "sha256": package.sha256, + "size": package.size, + } + ), + encoding="utf-8", + ) + requests = [] + monkeypatch.setattr( + update_checker.urllib.request, + "urlopen", + lambda *_args, **_kwargs: requests.append("request"), + ) + + with pytest.raises(update_checker.PackageIntegrityError, match="SHA-256"): + update_checker.VerifiedDownloader(cache_dir=tmp_path).download( + package, + cancel=threading.Event(), + ) + + assert requests == [] + assert not partial.exists() + assert not sidecar.exists() + assert not (tmp_path / "UTHelper-2.2.0.apk").exists() + + +def test_downloader_cancellation_removes_complete_partial(tmp_path, monkeypatch): + data = b"complete interrupted transfer" + package = _release_package(data) + partial = tmp_path / "UTHelper-2.2.0.apk.part" + sidecar = partial.with_suffix(partial.suffix + ".json") + partial.write_bytes(data) + sidecar.write_text( + json.dumps( + { + "url": package.url, + "sha256": package.sha256, + "size": package.size, + } + ), + encoding="utf-8", + ) + cancel = threading.Event() + cancel.set() + requests = [] + monkeypatch.setattr( + update_checker.urllib.request, + "urlopen", + lambda *_args, **_kwargs: requests.append("request"), + ) + + with pytest.raises(update_checker.DownloadCancelled): + update_checker.VerifiedDownloader(cache_dir=tmp_path).download( + package, + cancel=cancel, + ) + + assert requests == [] + assert not partial.exists() + assert not sidecar.exists() + assert not (tmp_path / "UTHelper-2.2.0.apk").exists() + + +def test_downloader_cancellation_precedes_verified_cache_reuse(tmp_path, monkeypatch): + data = b"cached verified apk" + destination = tmp_path / "UTHelper-2.2.0.apk" + destination.write_bytes(data) + cancel = threading.Event() + cancel.set() + requests = [] + monkeypatch.setattr( + update_checker.urllib.request, + "urlopen", + lambda *_args, **_kwargs: requests.append("request"), + ) + + with pytest.raises(update_checker.DownloadCancelled): + update_checker.VerifiedDownloader(cache_dir=tmp_path).download( + _release_package(data), + cancel=cancel, + ) + + assert destination.read_bytes() == data + assert requests == [] + + +def test_downloader_does_not_retry_or_sleep_after_deadline(tmp_path, monkeypatch): + now = [10.0] + requests = [] + sleeps = [] + + def fail_after_deadline(request, timeout): + requests.append((request, timeout)) + now[0] = 20.0 + raise ConnectionResetError("deadline elapsed during request") + + monkeypatch.setattr(update_checker.time, "monotonic", lambda: now[0]) + monkeypatch.setattr(update_checker.time, "sleep", sleeps.append) + monkeypatch.setattr( + update_checker.urllib.request, + "urlopen", + fail_after_deadline, + ) + + with pytest.raises(TimeoutError, match="total deadline"): + update_checker.VerifiedDownloader( + cache_dir=tmp_path, + total_timeout_seconds=10, + ).download( + _release_package(b"expected"), + cancel=threading.Event(), + ) + + assert len(requests) == 1 + assert requests[0][1] == 10 + assert sleeps == [] + + def test_progress_throttler_bounds_events_and_keeps_endpoints(): total = 46_882_816 chunk = 64 * 1024 @@ -351,6 +592,73 @@ def open_request(request, timeout): assert not any(tmp_path.glob("*.json")) +def test_downloader_resumes_after_truncated_response(tmp_path, monkeypatch): + data = b"firstsecond" + package = _release_package(data) + requests = [] + responses = iter( + [ + _Response(b"first"), + _Response( + b"second", + status=206, + headers={"Content-Range": "bytes 5-10/11"}, + ), + ] + ) + + def open_request(request, timeout): + requests.append((request.get_header("Range"), timeout)) + return next(responses) + + monkeypatch.setattr(update_checker.urllib.request, "urlopen", open_request) + monkeypatch.setattr(update_checker.time, "sleep", lambda _delay: None) + + path = update_checker.VerifiedDownloader(cache_dir=tmp_path).download( + package, + cancel=threading.Event(), + ) + + assert path.read_bytes() == data + assert requests == [(None, 20), ("bytes=5-", 20)] + + +def test_downloader_continues_valid_partial_range_responses(tmp_path, monkeypatch): + data = b"firstsecond" + package = _release_package(data) + requests = [] + responses = iter( + [ + _InterruptedResponse(b"first"), + _Response( + b"sec", + status=206, + headers={"Content-Range": "bytes 5-7/11"}, + ), + _Response( + b"ond", + status=206, + headers={"Content-Range": "bytes 8-10/11"}, + ), + ] + ) + + def open_request(request, timeout): + requests.append(request.get_header("Range")) + return next(responses) + + monkeypatch.setattr(update_checker.urllib.request, "urlopen", open_request) + monkeypatch.setattr(update_checker.time, "sleep", lambda _delay: None) + + path = update_checker.VerifiedDownloader(cache_dir=tmp_path).download( + package, + cancel=threading.Event(), + ) + + assert path.read_bytes() == data + assert requests == [None, "bytes=5-", "bytes=8-"] + + def test_downloader_restarts_safely_when_server_ignores_range(tmp_path, monkeypatch): data = b"firstsecond" package = _release_package(data) diff --git a/tests/test_update_coordinator.py b/tests/test_update_coordinator.py index 9c8ece1..148f81d 100644 --- a/tests/test_update_coordinator.py +++ b/tests/test_update_coordinator.py @@ -116,10 +116,11 @@ class _Launcher: def __init__(self): self.calls = [] self.cancel_calls = 0 + self.result = LaunchResult(True) def launch(self, path, package): self.calls.append((path, package)) - return LaunchResult(True) + return self.result def cancel(self): self.cancel_calls += 1 @@ -213,10 +214,18 @@ def test_verified_download_never_launches_until_explicit_confirmation(tmp_path): _wait_for(events, UpdateEventKind.READY_TO_INSTALL) assert downloader.calls == 1 assert launcher.calls == [] + kinds = [item.kind for item in events["items"]] + assert kinds.index(UpdateEventKind.VERIFYING) < kinds.index( + UpdateEventKind.READY_TO_INSTALL + ) coordinator.confirm_install() _wait_for(events, UpdateEventKind.INSTALL_LAUNCHED) assert len(launcher.calls) == 1 + kinds = [item.kind for item in events["items"]] + assert kinds.index(UpdateEventKind.INSTALLING) < kinds.index( + UpdateEventKind.INSTALL_LAUNCHED + ) assert coordinator.shutdown(timeout_seconds=1.0) is True @@ -258,6 +267,163 @@ def test_same_candidate_reuses_verified_download_after_confirmation_is_deferred( assert coordinator.shutdown(timeout_seconds=1.0) is True +def test_same_candidate_recheck_retains_verified_download(tmp_path): + candidate = _candidate() + coordinator, _, downloader, _, events = _coordinator( + tmp_path, + candidate=candidate, + ) + coordinator.request_download(candidate) + _wait_for(events, UpdateEventKind.READY_TO_INSTALL) + assert downloader.calls == 1 + + events["items"].clear() + events["changed"].clear() + coordinator.check_now() + _wait_for(events, UpdateEventKind.UPDATE_AVAILABLE) + + events["items"].clear() + events["changed"].clear() + coordinator.request_download(candidate) + _wait_for(events, UpdateEventKind.READY_TO_INSTALL) + + assert downloader.calls == 1 + assert downloader.path.is_file() + assert coordinator.shutdown(timeout_seconds=1.0) is True + + +def test_install_failure_retry_reuses_verified_download(tmp_path): + candidate = _candidate() + coordinator, _, downloader, launcher, events = _coordinator( + tmp_path, + candidate=candidate, + ) + coordinator.request_download(candidate) + _wait_for(events, UpdateEventKind.READY_TO_INSTALL) + launcher.result = LaunchResult(False, "installer is unavailable", "launch_failed") + coordinator.confirm_install() + _wait_for(events, UpdateEventKind.INSTALL_FAILED) + assert downloader.calls == 1 + assert downloader.path.is_file() + + events["items"].clear() + events["changed"].clear() + launcher.result = LaunchResult(True, "installer opened", "installer_opened") + coordinator.request_download(candidate) + _wait_for(events, UpdateEventKind.READY_TO_INSTALL) + coordinator.confirm_install() + _wait_for(events, UpdateEventKind.INSTALL_LAUNCHED) + + assert downloader.calls == 1 + assert len(launcher.calls) == 2 + assert coordinator.shutdown(timeout_seconds=1.0) is True + + +def test_permission_required_keeps_package_for_confirmed_retry(tmp_path): + candidate = _candidate() + coordinator, _, downloader, launcher, events = _coordinator( + tmp_path, + candidate=candidate, + ) + coordinator.request_download(candidate) + _wait_for(events, UpdateEventKind.READY_TO_INSTALL) + launcher.result = LaunchResult( + False, + "Allow installing apps to continue", + "permission_required", + ) + coordinator.confirm_install() + _wait_for(events, UpdateEventKind.INSTALL_PERMISSION_REQUIRED) + assert events["items"][-1].message == "Allow installing apps to continue" + assert downloader.calls == 1 + assert downloader.path.is_file() + + events["items"].clear() + events["changed"].clear() + launcher.result = LaunchResult(True, "installer opened", "installer_opened") + coordinator.request_download(candidate) + _wait_for(events, UpdateEventKind.READY_TO_INSTALL) + coordinator.confirm_install() + _wait_for(events, UpdateEventKind.INSTALL_LAUNCHED) + + assert downloader.calls == 1 + assert len(launcher.calls) == 2 + assert coordinator.shutdown(timeout_seconds=1.0) is True + + +def test_existing_download_is_observed_after_update_check_without_duplicate_queue( + tmp_path, +): + coordinator, _, downloader, _, events = _coordinator(tmp_path) + pending_calls = [] + downloader.block = True + downloader.has_pending_download = lambda package: pending_calls.append(package) or True + + coordinator.start() + _wait_for(events, UpdateEventKind.UPDATE_AVAILABLE) + assert downloader.started.wait(1.0) + candidate = _candidate() + for _ in range(5): + coordinator.request_download(candidate) + + assert downloader.calls == 1 + assert pending_calls == [candidate.package] + assert coordinator.shutdown(timeout_seconds=1.0) is True + assert downloader.cancelled.wait(1.0) + + +def test_explicit_download_cancellation_allows_a_cached_candidate_retry(tmp_path): + candidate = _candidate() + coordinator, _, downloader, _, events = _coordinator( + tmp_path, + candidate=candidate, + ) + downloader.block = True + coordinator.request_download(candidate) + assert downloader.started.wait(1.0) + coordinator.cancel_download() + _wait_for(events, UpdateEventKind.CANCELLED) + + downloader.block = False + coordinator.request_download(candidate) + _wait_for(events, UpdateEventKind.READY_TO_INSTALL) + + assert downloader.calls == 2 + assert coordinator.shutdown(timeout_seconds=1.0) is True + + +def test_shutdown_detaches_durable_download_and_preserves_completed_path(tmp_path): + class _DetachableDownloader(_Downloader): + def __init__(self, path): + super().__init__(path) + self.detached = threading.Event() + + def has_pending_download(self, _package): + return True + + def detach(self): + self.detached.set() + + def download(self, package, *, cancel, progress=None): + self.calls += 1 + self.started.set() + assert cancel.wait(2.0) + assert self.detached.is_set() + self.path.write_bytes(b"payload") + return self.path + + coordinator, _, _, _, _ = _coordinator(tmp_path) + downloader = _DetachableDownloader(tmp_path / "UTHelper.msi") + coordinator._downloader = downloader + coordinator.start() + assert downloader.started.wait(1.0) + + assert coordinator.shutdown(timeout_seconds=1.0) is True + + assert downloader.detached.is_set() + assert downloader.path.is_file() + + def test_confirmation_reverifies_retained_package_before_installer_handoff(tmp_path): coordinator, _, downloader, launcher, events = _coordinator(tmp_path) coordinator.request_download(_candidate()) From 84ad3ff0a233f4a6792db30d0e3d2d61e8a9430c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Thu, 12 Nov 2026 14:34:53 +0700 Subject: [PATCH 2/3] fix(updater): keep streaming responsive and validate native test setup --- .github/workflows/build-android.yml | 10 ++- docs/testing/auto-update-recovery.md | 51 ++++++++++---- .../update/ApkUpdateDownloadManager.kt | 17 ++++- .../update/ApkUpdateInstaller.kt | 25 ++++--- .../update/ApkUpdateDownloadManagerTest.kt | 25 ++++++- .../update/ApkUpdateInstallerFlowTest.kt | 61 +++++++++++++++- src/core/update_checker.py | 3 +- src/gui/app_controller.py | 9 +-- src/gui/components/settings/system_section.py | 2 +- src/gui/components/settings_view.py | 2 +- tests/test_update_checker.py | 70 +++++++++++++++++++ 11 files changed, 236 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index f5c90c3..3f6d2a1 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -88,7 +88,15 @@ jobs: - name: Test native update and background sync contracts timeout-minutes: 15 working-directory: build/flutter/android - run: ./gradlew :flet_uth_background_sync:testDebugUnitTest --no-daemon + run: | + # Test the source library independently: configuring the full app here + # would require Flet's transient serious_python packaging environment. + UTH_FLUTTER_SDK=$(python -c 'from pathlib import Path; print(next(line.split("=", 1)[1] for line in Path("local.properties").read_text().splitlines() if line.startswith("flutter.sdk=")))') + UTH_EMBEDDING_JAR=$(python -c 'import sys; from pathlib import Path; print(next((Path(sys.argv[1]) / "bin/cache/artifacts/engine").glob("android-*/flutter.jar")))' "$UTH_FLUTTER_SDK") + test -f "$UTH_EMBEDDING_JAR" + ./gradlew -p ../../../extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android \ + testDebugUnitTest -Pandroid.useAndroidX=true \ + -PflutterEmbeddingJar="$UTH_EMBEDDING_JAR" --no-daemon - name: 🔎 Verify notification manifest wiring run: | diff --git a/docs/testing/auto-update-recovery.md b/docs/testing/auto-update-recovery.md index f3aa410..dd1f35b 100644 --- a/docs/testing/auto-update-recovery.md +++ b/docs/testing/auto-update-recovery.md @@ -17,6 +17,12 @@ or tray issues, or #81's installer destination/payload optimization work. - The downloader imposed a shared 180-second transfer budget. Retries after expiry could not make useful progress. A clean EOF before the manifest size was treated as corrupt data and the otherwise reusable partial was removed. +- `HTTPResponse.read(64 KiB)` could aggregate multiple socket reads before + progress/cancellation/deadline checks. A real loopback server trickling 1 KiB + every 10 ms reproduced a 120 ms budget returning only after 704 ms, with no + intermediate progress. Using `read1` returned at 126 ms with 9 KiB of progress + in the same controlled experiment. This addresses client responsiveness, + not the provider's transfer bandwidth. - Completed packages were downloaded again after restarting the application; only partial-transfer reuse existed. A same-candidate metadata recheck also discarded the coordinator's reference to its already verified package. @@ -41,7 +47,8 @@ contract explicit, rather than claiming a missing final-release permission. ## Resulting behavior The desktop downloader has a finite 600-second budget, deadline-aware retries, -and remaining-time socket limits. A valid partial resumes with Range; a server +remaining-time socket limits and single-read streaming between progress/deadline +checks. A valid partial resumes with Range; a server that ignores Range safely restarts the file. A complete cache entry is used only after size/SHA-256 verification. Platform identity/signature checks still run before readiness and again before installation. @@ -81,16 +88,16 @@ One fresh download on the Windows host used the published `adf83d750656ada1b61c782326f4f27e7ffdea05cbd41722531573428afb217d`. It was verified without launching or installing it. -| Phase | Seconds | -|---|---:| -| GitHub discovery | 1.474 | -| Fresh download and SHA-256 | 22.219 | -| Windows signer/timestamp/product identity verification | 1.315 | -| Second use, rehashing the cached file | 0.084 | +| Phase | Initial patch, seconds | With single-read streaming, seconds | +|---|---:|---:| +| GitHub discovery | 1.474 | 1.134 | +| Fresh download and SHA-256 | 22.219 | 17.894 | +| Windows signer/timestamp/product identity verification | 1.315 | 1.040 | +| Second use, rehashing the cached file | 0.084 | 0.038 | -The second use forbade network calls and still succeeded. The fresh run emitted -173 progress events. This demonstrates working provider discovery, verified -transfer and zero-network reuse. It is one network sample, not a bandwidth +Both second uses forbade network calls and still succeeded. The fresh runs emitted +173 and 160 progress events. This demonstrates working provider discovery, verified +transfer and zero-network reuse. These are two network samples, not a bandwidth improvement benchmark, installation measurement or a download-plus-setup SLA. It cannot be compared as a controlled speedup against #81's earlier network samples. Raw measurements and package files remain outside version control. @@ -108,7 +115,7 @@ $env:PYTHONPATH='src;extensions/flet_uth_background_sync/src;.' .venv\Scripts\ruff.exe check src tests scripts ``` -The full local Python suite passed **1,670 tests**, skipping 25, and Ruff passed. +The full local Python suite passed **1,671 tests**, skipping 25, and Ruff passed. The native extension's production debug/release Kotlin compiled with SDK 36; an initial SDK-35 attempt exposed that the existing AndroidX Core 1.17.0 dependency requires SDK 36. The extension now compiles against 36, without @@ -116,10 +123,20 @@ changing minimum/target SDK. Robolectric regression tests simulate DownloadManager rows/files and native permission/intent handoff; they do not download from the real Android provider -or parse/install a signed APK. The initial local test task was blocked before -test execution by the local JDK's `jlink` missing `jdk.internal.vm.ci`; a stock -JDK retry and the new Android CI task provide separate verification. Do not -count successful production Kotlin compilation as a passing native test suite. +or parse/install a signed APK. A stock Temurin JDK 21 resolved the initial local +JDK-image transform failure (`jdk.internal.vm.ci` missing). The local download +lifecycle/metadata/cache tests execute, but Windows cannot execute AndroidX +FileProvider's real path handling: Core 1.17.0 compares canonical paths with a +literal `/`, whereas Windows returns `\`. The two intent-flow tests remain +enabled for Linux CI; no production path or test success is faked to hide this +platform limit. Standalone tests supply realistic pending DownloadManager rows +and resolved provider/installer metadata that a library unit-test app lacks. + +The first GitHub APK build succeeded; its new native-test step then failed because +configuring the entire app outside Flet lacked `SERIOUS_PYTHON_SITE_PACKAGES`. +The corrected step uses that build's Gradle wrapper and Flutter embedding JAR +to test the checked-in extension independently. This CI invocation defect is +separate from the updater runtime findings. A local Flet browser harness rendered the real changed update controls with synthetic release data, at desktop and 360-pixel width, using light/dark themes. @@ -127,6 +144,9 @@ Keyboard text entry continued during download. Readiness did not open a modal; the explicit install action opened confirmation, and Escape dismissed it with focus restored. This is scoped browser UI evidence, not Android lifecycle or Windows installation acceptance. No customer content was rendered or captured. +The banner now uses the existing primary text token: its contrast against the +banner background is at least 7.49:1 across all nine presets. The previous warning +text color measured 4.44:1 in Steel Light, below the small-text AA threshold. Required device acceptance before release remains: @@ -163,3 +183,4 @@ reset their data to bootstrap the fix. - [DownloadManager request notification behavior](https://developer.android.com/reference/android/app/DownloadManager.Request) - [Unknown-source installation access](https://developer.android.com/reference/android/content/pm/PackageManager#canRequestPackageInstalls()) - [Flet asynchronous applications](https://docs.flet.dev/cookbook/async-apps/) +- [Python buffered stream read/read1 behavior](https://docs.python.org/3.11/library/io.html#io.BufferedIOBase.read1) diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManager.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManager.kt index 71887a4..ea44d81 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManager.kt +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManager.kt @@ -243,15 +243,16 @@ class ApkUpdateDownloadManager(private val context: Context) { manager: DownloadManager, identity: UpdateDownloadIdentity, ): Long? { - val expectedLocalUri = Uri.fromFile(destinationFor(identity)).toString() + val expectedDestination = destinationFor(identity) manager.query(DownloadManager.Query()).use { cursor -> val idColumn = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID) val localUriColumn = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI) val uriColumn = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI) while (cursor.moveToNext()) { + val localUri = cursor.getString(localUriColumn) if ( cursor.getString(uriColumn) == identity.url && - cursor.getString(localUriColumn) == expectedLocalUri + isExpectedDestination(localUri, expectedDestination) ) { return cursor.getLong(idColumn) } @@ -260,6 +261,18 @@ class ApkUpdateDownloadManager(private val context: Context) { return null } + private fun isExpectedDestination(localUri: String?, expectedDestination: File): Boolean { + if (localUri.isNullOrBlank()) return false + val uri = Uri.parse(localUri) + if (uri.scheme != "file") return false + val downloadedFile = uri.path ?: return false + return try { + File(downloadedFile).canonicalFile == expectedDestination.canonicalFile + } catch (_: Exception) { + false + } + } + private fun readDownloadId(identity: UpdateDownloadIdentity): Long? { val value = preferences.getLong(downloadIdPreferenceKey(identity), NO_DOWNLOAD_ID) return value.takeIf { it != NO_DOWNLOAD_ID } diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstaller.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstaller.kt index e5351dc..529fe0d 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstaller.kt +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/main/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstaller.kt @@ -252,11 +252,6 @@ class ApkUpdateInstaller(private val context: Context) { expectedSize = expectedSize, cancelled = cancelled, ) - stage = if (prepared.reusedCache) { - "cached_package_verification" - } else { - "downloaded_package_verification" - } val verified = prepared.verification val archivePath = prepared.file @@ -310,11 +305,21 @@ class ApkUpdateInstaller(private val context: Context) { } stage = "installer_handoff" - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.uthupdates", - stagedApk, - ) + val uri = try { + FileProvider.getUriForFile( + context, + "${context.packageName}.uthupdates", + stagedApk, + ) + } catch (_: Exception) { + return InstallUpdateResult( + status = "failed", + stage = stage, + bytes = packageBytes, + sha256 = packageSha256, + reason = "file_provider_unavailable", + ) + } try { context.startActivity( Intent(Intent.ACTION_VIEW).apply { diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManagerTest.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManagerTest.kt index df40e92..175a496 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManagerTest.kt +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateDownloadManagerTest.kt @@ -15,10 +15,12 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows +import org.robolectric.annotation.Implementation import org.robolectric.annotation.Config +import org.robolectric.annotation.Implements @RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) +@Config(sdk = [35], shadows = [PendingShadowDownloadManager::class]) class ApkUpdateDownloadManagerTest { private val url = "https://github.com/Chouwzi/UTHelper/releases/download/v2.5.1/UTHelper-2.5.1.apk" @@ -40,6 +42,15 @@ class ApkUpdateDownloadManagerTest { val shadowRequest = Shadows.shadowOf(request) assertEquals(DownloadManager.Request.VISIBILITY_VISIBLE, shadowRequest.notificationVisibility) assertEquals("Đang tải bản cập nhật", shadowRequest.description.toString()) + val persistedRow = manager.query(DownloadManager.Query()).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)) + } + assertEquals( + "expected request destination URI", + shadowRequest.destination.toString(), + persistedRow, + ) context.getSharedPreferences("uth_update_downloads", Context.MODE_PRIVATE) .edit() @@ -48,7 +59,7 @@ class ApkUpdateDownloadManagerTest { val restoredDownloader = ApkUpdateDownloadManager(context) val restored = restoredDownloader.status(identity) - assertEquals("pending", restored.status) + assertEquals("status=${restored.status}, reason=${restored.reason}", "pending", restored.status) assertEquals(started.downloadId, restored.downloadId) val reused = restoredDownloader.start(identity) @@ -131,3 +142,13 @@ class ApkUpdateDownloadManagerTest { .digest(bytes) .joinToString("") { "%02x".format(it) } } + +/** Robolectric leaves a newly enqueued request's status at Java-default 0; Android reports pending. */ +@Implements(DownloadManager::class) +class PendingShadowDownloadManager : org.robolectric.shadows.ShadowDownloadManager() { + @Implementation + override fun enqueue(request: DownloadManager.Request): Long = + super.enqueue(request).also { + Shadows.shadowOf(request).setStatus(DownloadManager.STATUS_PENDING) + } +} diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt index f2b0eb0..90ac713 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt @@ -1,13 +1,20 @@ package com.uthelper.backgroundsync.update import android.content.Context +import android.content.ComponentName import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager import android.content.pm.PackageInfo +import android.content.pm.ProviderInfo import android.content.pm.Signature import android.net.Uri +import android.os.Bundle import android.provider.Settings import java.io.File import java.security.MessageDigest +import com.uthelper.backgroundsync.R import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -31,6 +38,7 @@ class ApkUpdateInstallerFlowTest { val context: Context = app val packageFile = fixturePackage(context) val identity = installIdentity(context, packageFile) + registerInstallerProvider(context) val installer = ApkUpdateInstaller(context) Shadows.shadowOf(context.packageManager).setCanRequestPackageInstalls(true) @@ -43,7 +51,7 @@ class ApkUpdateInstallerFlowTest { expectedCertificateSha256 = identity.certificateSha256, ) - assertEquals("installer_opened", result.status) + assertEquals("stage=${result.stage}, reason=${result.reason}", "installer_opened", result.status) assertEquals("installer_handoff", result.stage) assertEquals(archiveBytes.size.toLong(), result.bytes) assertEquals(identity.sha256, result.sha256) @@ -66,6 +74,7 @@ class ApkUpdateInstallerFlowTest { val context: Context = app val packageFile = fixturePackage(context) val identity = installIdentity(context, packageFile) + registerInstallerProvider(context) val installer = ApkUpdateInstaller(context) val shadowPackageManager = Shadows.shadowOf(context.packageManager) shadowPackageManager.setCanRequestPackageInstalls(false) @@ -103,7 +112,7 @@ class ApkUpdateInstallerFlowTest { expectedCertificateSha256 = identity.certificateSha256, ) - assertEquals("installer_opened", retried.status) + assertEquals("stage=${retried.stage}, reason=${retried.reason}", "installer_opened", retried.status) assertEquals("installer_handoff", retried.stage) assertEquals(identity.sha256, retried.sha256) val installerIntent = Shadows.shadowOf(app).getNextStartedActivity() @@ -117,6 +126,54 @@ class ApkUpdateInstallerFlowTest { writeBytes(archiveBytes) }.canonicalFile + private fun registerInstallerProvider(context: Context) { + val authority = "${context.packageName}.uthupdates" + val provider = ProviderInfo().apply { + packageName = context.packageName + name = UthUpdateFileProvider::class.java.name + this.authority = authority + exported = false + grantUriPermissions = true + applicationInfo = context.applicationInfo + metaData = Bundle().apply { + putInt("android.support.FILE_PROVIDER_PATHS", R.xml.uth_update_paths) + } + } + Shadows.shadowOf(context.packageManager).addOrUpdateProvider(provider) + val shadowPackageManager = Shadows.shadowOf(context.packageManager) + val installerActivity = ActivityInfo().apply { + packageName = context.packageName + name = "${context.packageName}.TestPackageInstallerActivity" + applicationInfo = context.applicationInfo + } + val installerComponent = ComponentName(context.packageName, installerActivity.name) + shadowPackageManager.addOrUpdateActivity(installerActivity) + shadowPackageManager.addIntentFilterForActivity( + installerComponent, + IntentFilter(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_DEFAULT) + addDataScheme("content") + addDataType("application/vnd.android.package-archive") + }, + ) + val resolved = context.packageManager.resolveContentProvider( + authority, + PackageManager.GET_META_DATA, + ) + assertNotNull("unit-test provider metadata for $authority", resolved) + assertEquals( + R.xml.uth_update_paths, + resolved!!.metaData.getInt("android.support.FILE_PROVIDER_PATHS"), + ) + assertNotNull( + "test provider XML for ${resolved.authority}", + resolved.loadXmlMetaData( + context.packageManager, + "android.support.FILE_PROVIDER_PATHS", + ), + ) + } + private fun installIdentity(context: Context, packageFile: File): InstallIdentity { val signature = Signature(signerBytes) val installed = PackageInfo().apply { diff --git a/src/core/update_checker.py b/src/core/update_checker.py index ce6a51b..c58f120 100644 --- a/src/core/update_checker.py +++ b/src/core/update_checker.py @@ -523,7 +523,8 @@ def _download_attempt( response, min(float(_SOCKET_TIMEOUT_SECONDS), remaining), ) - chunk = response.read(_DOWNLOAD_CHUNK_SIZE) + # Avoid waiting for a full buffer before checking progress/deadline. + chunk = response.read1(_DOWNLOAD_CHUNK_SIZE) self._remaining_seconds(deadline) if cancel.is_set(): raise DownloadCancelled("download cancelled") diff --git a/src/gui/app_controller.py b/src/gui/app_controller.py index dd75b2e..6e85ab7 100644 --- a/src/gui/app_controller.py +++ b/src/gui/app_controller.py @@ -120,6 +120,7 @@ def _update_install_error_message(status: str) -> str: "size_mismatch": "Gói cập nhật chưa đầy đủ. Hãy tải lại.", "already_installed": "Bản cập nhật này đã được cài đặt.", "package_installer_unavailable": "Không tìm thấy trình cài đặt trên thiết bị.", + "file_provider_unavailable": "Không chuyển được gói tới trình cài đặt. Hãy thử lại.", "permission_settings_unavailable": "Không mở được quyền cài ứng dụng. Hãy kiểm tra Cài đặt Android.", } return messages.get(status, "Không mở được trình cài đặt. Hãy thử lại.") @@ -726,7 +727,7 @@ def _init_banner_and_states(self): self._update_text = ft.Text( "Có phiên bản mới!", size=12, - color=C.UPDATE, + color=C.TEXT_PRIMARY, weight=ft.FontWeight.W_500, expand=True, ) @@ -740,7 +741,7 @@ def _init_banner_and_states(self): self._update_btn = ft.TextButton( "Cập nhật", style=ft.ButtonStyle( - color=C.UPDATE, shape=ft.RoundedRectangleBorder(radius=8), + color=C.TEXT_PRIMARY, shape=ft.RoundedRectangleBorder(radius=8), ), on_click=self._request_update_download, ) @@ -2391,11 +2392,11 @@ def _rebuild_colors(self): # Update banner is a semantic elevated surface, not a fixed brown card. self._update_banner.bgcolor = _C.SURFACE_ELEVATED self._update_banner.border = ft.Border.all(1, _C.UPDATE) - self._update_text.color = _C.UPDATE + self._update_text.color = _C.TEXT_PRIMARY self._update_progress.color = _C.UPDATE self._update_progress.bgcolor = _C.BORDER self._update_btn.style = ft.ButtonStyle( - color=_C.UPDATE, shape=ft.RoundedRectangleBorder(radius=8), + color=_C.TEXT_PRIMARY, shape=ft.RoundedRectangleBorder(radius=8), ) self._update_cancel_btn.style = ft.ButtonStyle( color=_C.TEXT_SECONDARY, shape=ft.RoundedRectangleBorder(radius=8), diff --git a/src/gui/components/settings/system_section.py b/src/gui/components/settings/system_section.py index 6ec01df..a60a4ab 100644 --- a/src/gui/components/settings/system_section.py +++ b/src/gui/components/settings/system_section.py @@ -16,7 +16,7 @@ def init_system_controls(view): "Kiểm tra ngay", on_click=getattr(view, "_handle_check_update", None), bgcolor=C.SURFACE, - color=C.ACCENT, + color=C.TEXT_PRIMARY, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8)), ) # Thiết lập riêng trên Desktop (Windows) diff --git a/src/gui/components/settings_view.py b/src/gui/components/settings_view.py index c72c9c8..7db22ab 100644 --- a/src/gui/components/settings_view.py +++ b/src/gui/components/settings_view.py @@ -174,7 +174,7 @@ def _init_controls(self): "", visible=False, bgcolor=C.SURFACE, - color=C.ACCENT, + color=C.TEXT_PRIMARY, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8)), on_click=self._handle_update_action, ) diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py index bc0ff30..f95c4a9 100644 --- a/tests/test_update_checker.py +++ b/tests/test_update_checker.py @@ -136,6 +136,9 @@ def __exit__(self, *_args): def read(self, size=-1): return self._stream.read(size) + def read1(self, size=-1): + return self.read(size) + class _ChunkedResponse(_Response): def __init__(self, chunks, after_first=None): @@ -168,6 +171,36 @@ def read(self, _size=-1): raise ConnectionResetError("simulated connection reset") +class _AdvancingClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +class _DeadlineRead1Response(_Response): + def __init__(self, data, clock, *, chunk_size=1024, delay_seconds=0.1): + super().__init__(data) + self.clock = clock + self.chunk_size = chunk_size + self.delay_seconds = delay_seconds + self.read_calls = 0 + self.read1_calls = 0 + + def read(self, _size=-1): + self.read_calls += 1 + raise AssertionError("downloader used an aggregating read") + + def read1(self, size=-1): + self.read1_calls += 1 + self.clock.advance(self.delay_seconds) + return self._stream.read(min(size, self.chunk_size)) + + def _release_package(data: bytes) -> ReleasePackage: return ReleasePackage( platform="android", @@ -537,6 +570,43 @@ def fail_after_deadline(request, timeout): assert sleeps == [] +def test_downloader_checks_deadline_and_reports_progress_between_single_reads( + tmp_path, + monkeypatch, +): + data = b"x" * (8 * 1024) + package = _release_package(data) + clock = _AdvancingClock() + response = _DeadlineRead1Response(data, clock) + requests = [] + progress = [] + monkeypatch.setattr(update_checker.time, "monotonic", clock) + + def open_request(request, timeout): + requests.append((request, timeout)) + return response + + monkeypatch.setattr(update_checker.urllib.request, "urlopen", open_request) + + with pytest.raises(TimeoutError, match="total deadline"): + update_checker.VerifiedDownloader( + cache_dir=tmp_path, + total_timeout_seconds=0.25, + ).download( + package, + cancel=threading.Event(), + progress=lambda done, total: progress.append((done, total)), + ) + + assert requests[0][1] == 0.25 + assert response.read_calls == 0 + assert response.read1_calls == 3 + assert clock.now >= 0.3 + assert [done for done, _total in progress] == [0, 1024, 2048] + partial = tmp_path / "UTHelper-2.2.0.apk.part" + assert partial.read_bytes() == b"x" * 2048 + + def test_progress_throttler_bounds_events_and_keeps_endpoints(): total = 46_882_816 chunk = 64 * 1024 From 89c3b3d22963ff6bd8594230ca86130d59f81a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Thu, 12 Nov 2026 14:52:53 +0700 Subject: [PATCH 3/3] test(android): initialize provider lifecycle for installer retry --- .../flutter/flet_uth_background_sync/android/build.gradle | 7 +++++++ .../backgroundsync/update/ApkUpdateInstallerFlowTest.kt | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/build.gradle b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/build.gradle index b795d4e..c3f40e5 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/build.gradle +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/build.gradle @@ -39,6 +39,13 @@ android { } } +tasks.withType(org.gradle.api.tasks.testing.Test).configureEach { + testLogging { + events "failed" + exceptionFormat "full" + } +} + dependencies { if (project.hasProperty('flutterEmbeddingJar')) { compileOnly files(project.property('flutterEmbeddingJar')) diff --git a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt index 90ac713..5242fc2 100644 --- a/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt +++ b/extensions/flet_uth_background_sync/flutter/flet_uth_background_sync/android/src/test/kotlin/com/uthelper/backgroundsync/update/ApkUpdateInstallerFlowTest.kt @@ -139,8 +139,12 @@ class ApkUpdateInstallerFlowTest { putInt("android.support.FILE_PROVIDER_PATHS", R.xml.uth_update_paths) } } - Shadows.shadowOf(context.packageManager).addOrUpdateProvider(provider) val shadowPackageManager = Shadows.shadowOf(context.packageManager) + // Standalone library tests have no consumer applicationId to resolve this manifest value. + shadowPackageManager.addOrUpdateProvider(provider) + // Mirror Android provider initialization; FileProvider.attachInfo clears its static + // per-authority path cache before resolving this test Context's cache directory. + UthUpdateFileProvider().attachInfo(context, provider) val installerActivity = ActivityInfo().apply { packageName = context.packageName name = "${context.packageName}.TestPackageInstallerActivity"