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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/build-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ 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: |
# 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: |
mapfile -t APK_CANDIDATES < <(find build/apk -maxdepth 1 -name '*.apk' -type f | sort)
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ flet_build/

# ── Android SDK/Gradle ──
.gradle/
.kotlin/
local.properties
*.hprof

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
186 changes: 186 additions & 0 deletions docs/testing/auto-update-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# 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.
- `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.
- 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,
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.

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 | 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 |

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.

## 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,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
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. 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.
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:

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/)
- [Python buffered stream read/read1 behavior](https://docs.python.org/3.11/library/io.html#io.BufferedIOBase.read1)
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ apply plugin: 'kotlin-kapt'

android {
namespace 'com.uthelper.backgroundsync'
compileSdk 35
compileSdk 36

defaultConfig {
minSdk 23
Expand All @@ -33,6 +33,17 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}

testOptions {
unitTests.includeAndroidResources = true
}
}

tasks.withType(org.gradle.api.tasks.testing.Test).configureEach {
testLogging {
events "failed"
exceptionFormat "full"
}
}

dependencies {
Expand All @@ -45,4 +56,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'
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -205,6 +220,27 @@ class UthBackgroundSyncPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}

private fun updateIdentity(call: MethodCall): UpdateDownloadIdentity =
UpdateDownloadIdentity(
url = call.argument<String>("url").orEmpty(),
sha256 = call.argument<String>("sha256").orEmpty(),
expectedSize = call.argument<Number>("expected_size")?.toLong() ?: 0L,
expectedPackageId = call.argument<String>("expected_package_id").orEmpty(),
expectedVersionCode = call.argument<Number>("expected_version_code")?.toLong() ?: 0L,
expectedCertificateSha256 =
call.argument<String>("expected_certificate_sha256").orEmpty(),
)

private fun com.uthelper.backgroundsync.update.UpdateDownloadResult.toMap(): Map<String, Any?> =
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?,
Expand Down
Loading
Loading