Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -764,15 +783,17 @@ internal fun settleUpdatePartial(

internal fun cleanupAndroidUpdatePackages(
directory: File,
activePartial: File,
activePackages: Set<File>,
): 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." }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand All @@ -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()
}
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/android-update-installer-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: 176
pull: 401
platforms: android
user-facing: yes

Let Android users reopen a dismissed update confirmation without downloading the verified APK again.
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines +13389 to +13393

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit the APK retry action to Android

On direct-package desktop builds, ConfirmationOpened also reaches this shared branch, but clicking this button calls requestInstall, then DesktopAppUpdater.beginUpdate, which cleans the staged package and starts downloading again from zero. The desktop UI therefore promises APK reuse that only Android implements; make this wording/action Android-specific or add equivalent desktop package reuse.

AGENTS.md reference: AGENTS.md:L78-L85

Useful? React with 👍 / 👎.

}
}
is AppUpdateInstallState.Installed -> Text(
"The update was installed. Restart Nextcloud Native to use the new version.",
style = MaterialTheme.typography.bodySmall,
Expand Down
2 changes: 1 addition & 1 deletion website/public/screenshots/capture-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading