From 219a9b4ed4d7fe9375d4a4eaa8be9916f0b50461 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 16 Aug 2026 19:21:32 +0200 Subject: [PATCH 1/4] fix(updates): reuse Android APK after installer dismissal --- .../AndroidNextcloudServices.kt | 5 ++ .../AndroidProjectContentClient.kt | 67 ++++++++++++------- .../AndroidProjectContentClientTest.kt | 14 ++-- .../android-update-installer-retry.md | 7 ++ .../nextcloudnative/app/NextcloudNativeApp.kt | 18 +++-- 5 files changed, 79 insertions(+), 32 deletions(-) create mode 100644 changes/unreleased/android-update-installer-retry.md diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index ef6656099..88484e89f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -673,6 +673,11 @@ internal class AndroidNextcloudServices( is AppUpdateInstallResult.Rejected -> "rejected" }, durationMillis = elapsedMillis(started), + message = when (result) { + is AppUpdateInstallResult.PermissionRequired -> result.message + is AppUpdateInstallResult.Rejected -> result.message + else -> null + }, fields = listOf(SupportDiagnosticFieldDraft("release", release.versionName)), ), ) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt index 15bf04dda..24a431a1d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt @@ -332,10 +332,21 @@ internal class AndroidProjectContentClient( val temporary = File(updateDirectory, "${staged.name}.part") cleanupAndroidUpdatePackages( directory = updateDirectory, - activePartial = temporary, + activePackages = setOf(temporary, staged), ) updateCancellationRequested = false return try { + if (staged.isFile) { + mutableUpdateState.value = AppUpdateInstallState.Verifying( + versionName = release.versionName, + versionCode = release.versionCode, + ) + val reusable = runCatching { verifyDownloadedApk(release, staged) }.isSuccess + if (reusable) { + return openUpdateInstaller(foregroundActivity, release, staged) + } + check(staged.delete()) { "Could not discard an invalid cached update." } + } val resumedFromBytes = settleUpdatePartial( file = temporary, expectedSize = release.apkSize, @@ -373,25 +384,7 @@ internal class AndroidProjectContentClient( verifyDownloadedApk(release, temporary) if (staged.exists()) check(staged.delete()) check(temporary.renameTo(staged)) { "Could not stage the verified update." } - val uri = FileProvider.getUriForFile( - appContext, - "${appContext.packageName}.sharedfiles", - staged, - ) - withContext(Dispatchers.Main.immediate) { - foregroundActivity.startActivity( - Intent(Intent.ACTION_INSTALL_PACKAGE).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - putExtra(Intent.EXTRA_RETURN_RESULT, false) - }, - ) - } - mutableUpdateState.value = AppUpdateInstallState.ConfirmationOpened( - versionName = release.versionName, - versionCode = release.versionCode, - ) - AppUpdateInstallResult.ConfirmationOpened + openUpdateInstaller(foregroundActivity, release, staged) } catch (_: UpdateDownloadCancelledException) { val retainedBytes = settleUpdatePartial( file = temporary, @@ -441,6 +434,32 @@ internal class AndroidProjectContentClient( } } + private suspend fun openUpdateInstaller( + foregroundActivity: Activity, + release: AndroidDirectRelease, + staged: File, + ): AppUpdateInstallResult { + val uri = FileProvider.getUriForFile( + appContext, + "${appContext.packageName}.sharedfiles", + staged, + ) + withContext(Dispatchers.Main.immediate) { + foregroundActivity.startActivity( + Intent(Intent.ACTION_INSTALL_PACKAGE).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_RETURN_RESULT, false) + }, + ) + } + mutableUpdateState.value = AppUpdateInstallState.ConfirmationOpened( + versionName = release.versionName, + versionCode = release.versionCode, + ) + return AppUpdateInstallResult.ConfirmationOpened + } + private fun AndroidDirectRelease.downloadingState( downloadedBytes: Long, resumedFromBytes: Long, @@ -764,15 +783,17 @@ internal fun settleUpdatePartial( internal fun cleanupAndroidUpdatePackages( directory: File, - activePartial: File, + activePackages: Set, ): Int { if (!directory.isDirectory) return 0 - val activePath = activePartial.toPath().toAbsolutePath().normalize() + val activePaths = activePackages.mapTo(mutableSetOf()) { + it.toPath().toAbsolutePath().normalize() + } var removed = 0 directory.listFiles().orEmpty().forEach { candidate -> if ( candidate.androidUpdatePackageVersionCode() != null && - candidate.toPath().toAbsolutePath().normalize() != activePath && + candidate.toPath().toAbsolutePath().normalize() !in activePaths && Files.isRegularFile(candidate.toPath(), LinkOption.NOFOLLOW_LINKS) ) { check(candidate.delete()) { "Could not clear an obsolete Android update package." } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClientTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClientTest.kt index a928d40a0..e2a4c80b8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClientTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClientTest.kt @@ -145,7 +145,7 @@ class AndroidProjectContentClientTest { } @Test - fun obsoleteAndroidUpdatePackagesAreRemovedWithoutTouchingTheActivePartial() { + fun obsoleteAndroidUpdatePackagesAreRemovedWithoutTouchingTheActiveRetryFiles() { val directory = Files.createTempDirectory("project-content-update-cleanup-test").toFile() try { val oldStaged = directory.resolve("nextcloud-native-20.apk").apply { writeText("old") } @@ -155,14 +155,20 @@ class AndroidProjectContentClientTest { val unrelated = directory.resolve("README.txt").apply { writeText("keep") } val malformed = directory.resolve("nextcloud-native-invalid.apk.part").apply { writeText("keep") } - assertEquals(3, cleanupAndroidUpdatePackages(directory, activePartial)) + assertEquals( + 2, + cleanupAndroidUpdatePackages(directory, setOf(activePartial, stagedForRetry)), + ) assertFalse(oldStaged.exists()) assertFalse(oldPartial.exists()) - assertFalse(stagedForRetry.exists()) + assertTrue(stagedForRetry.isFile) assertTrue(activePartial.isFile) assertTrue(unrelated.isFile) assertTrue(malformed.isFile) - assertEquals(0, cleanupAndroidUpdatePackages(directory, activePartial)) + assertEquals( + 0, + cleanupAndroidUpdatePackages(directory, setOf(activePartial, stagedForRetry)), + ) } finally { directory.deleteRecursively() } diff --git a/changes/unreleased/android-update-installer-retry.md b/changes/unreleased/android-update-installer-retry.md new file mode 100644 index 000000000..2b9787f39 --- /dev/null +++ b/changes/unreleased/android-update-installer-retry.md @@ -0,0 +1,7 @@ +category: fixed +issue: 176 +pull: none +platforms: android +user-facing: yes + +Let Android users reopen a dismissed update confirmation without downloading the verified APK again. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 43ca7923b..624ef9fb2 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -13380,11 +13380,19 @@ private fun AppUpdateSettingsCard( Text("Continue update") } } - is AppUpdateInstallState.ConfirmationOpened -> Text( - "The system installer opened the update confirmation.", - style = MaterialTheme.typography.bodySmall, - color = NextcloudTheme.colors.success, - ) + is AppUpdateInstallState.ConfirmationOpened -> { + Text( + "The system installer opened the update confirmation. If you closed it, open it again without downloading the APK again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { requestInstall(release) }, + enabled = !installing, + ) { + Text("Open installer again") + } + } is AppUpdateInstallState.Installed -> Text( "The update was installed. Restart Nextcloud Native to use the new version.", style = MaterialTheme.typography.bodySmall, From 6779f096cbbf676186d5ae402a15132bca4af4f1 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 16 Aug 2026 19:23:38 +0200 Subject: [PATCH 2/4] chore(changelog): use Android update fix category --- changes/unreleased/android-update-installer-retry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/android-update-installer-retry.md b/changes/unreleased/android-update-installer-retry.md index 2b9787f39..680a170e6 100644 --- a/changes/unreleased/android-update-installer-retry.md +++ b/changes/unreleased/android-update-installer-retry.md @@ -1,4 +1,4 @@ -category: fixed +category: fix issue: 176 pull: none platforms: android From b6590b02748b05138991a43ba5c4287e71c2e84a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 16 Aug 2026 19:45:10 +0200 Subject: [PATCH 3/4] chore(changelog): link Android update retry PR --- changes/unreleased/android-update-installer-retry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/android-update-installer-retry.md b/changes/unreleased/android-update-installer-retry.md index 680a170e6..a65830abd 100644 --- a/changes/unreleased/android-update-installer-retry.md +++ b/changes/unreleased/android-update-installer-retry.md @@ -1,6 +1,6 @@ category: fix issue: 176 -pull: none +pull: 401 platforms: android user-facing: yes From 940e05271da0fc96ce30afba7e15acc306d3f3ec Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:52:19 +0000 Subject: [PATCH 4/4] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 31c82345e..79504c9cb 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -377,7 +377,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "4ea7b9a1979db26b71944ade4b1f8eafff7e10c85b79479cf79f3da8b473dfbf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "0917961c491ddecdc125e5d59f5ebf07654e0a1d745b64c460402d9140d296c0", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "43657dea10dfa97e1d3c6f5ce1a753083e11e74700604da010897ba593f0814c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "14d43a632afa7c5bf970d90d1387285624182be00569b57c0955670de017cc6c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "9223dd455c6a1c35a10769616fceb9dfb2d92ef4d43b0a52c2c29e40f1a196cf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b",