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 @@ -8,7 +8,6 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import dev.obiente.nextcloudnative.app.AndroidDirectRelease
import dev.obiente.nextcloudnative.app.AndroidUpdateChannel
import dev.obiente.nextcloudnative.app.AppDistributionChannel
Expand All @@ -35,6 +34,7 @@ import dev.obiente.nextcloudnative.app.manifestUrl
import dev.obiente.nextcloudnative.app.parseAndroidDirectRelease
import dev.obiente.nextcloudnative.app.parseAndroidUpdateChannel
import dev.obiente.nextcloudnative.app.parseProjectNewsFeed
import dev.obiente.nextcloudnative.app.runCatchingPreservingCancellation
import dev.obiente.nextcloudnative.app.validateAndroidDirectRelease
import java.io.File
import java.io.FileOutputStream
Expand Down Expand Up @@ -344,11 +344,27 @@ internal class AndroidProjectContentClient(
val temporary = File(updateDirectory, "${staged.name}.part")
cleanupAndroidUpdatePackages(
directory = updateDirectory,
activePartial = temporary,
activePackages = setOf(temporary, staged),
)
updateCancellationRequested = false
var diagnosticStage = "download"
return try {
if (staged.isFile) {
mutableUpdateState.value = AppUpdateInstallState.Verifying(
versionName = release.versionName,
versionCode = release.versionCode,
)
diagnosticStage = "verification"
val reusable = runCatchingPreservingCancellation {
verifyDownloadedApk(release, staged)
}.isSuccess
if (reusable) {
diagnosticStage = "installer-handoff"
return openAndroidUpdateInstaller(appContext, foregroundActivity, release, staged, mutableUpdateState)
}
check(staged.delete()) { "Could not discard an invalid cached update." }
}
diagnosticStage = "download"
val resumedFromBytes = settleUpdatePartial(
file = temporary,
expectedSize = release.apkSize,
Expand Down Expand Up @@ -388,25 +404,7 @@ internal class AndroidProjectContentClient(
if (staged.exists()) check(staged.delete())
check(temporary.renameTo(staged)) { "Could not stage the verified update." }
diagnosticStage = "installer-handoff"
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
openAndroidUpdateInstaller(appContext, foregroundActivity, release, staged, mutableUpdateState)
} catch (_: UpdateDownloadCancelledException) {
val retainedBytes = settleUpdatePartial(
file = temporary,
Expand Down Expand Up @@ -782,15 +780,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
@@ -0,0 +1,37 @@
package dev.obiente.nextcloudnative

import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import dev.obiente.nextcloudnative.app.AndroidDirectRelease
import dev.obiente.nextcloudnative.app.AppUpdateInstallResult
import dev.obiente.nextcloudnative.app.AppUpdateInstallState
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext

internal suspend fun openAndroidUpdateInstaller(
context: Context,
activity: Activity,
release: AndroidDirectRelease,
staged: File,
updateState: MutableStateFlow<AppUpdateInstallState>,
): AppUpdateInstallResult {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.sharedfiles", staged)
withContext(Dispatchers.Main.immediate) {
activity.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)
},
)
}
updateState.value = AppUpdateInstallState.ConfirmationOpened(
versionName = release.versionName,
versionCode = release.versionCode,
)
return AppUpdateInstallResult.ConfirmationOpened
}
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 @@ -532,11 +532,25 @@ internal 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(
if (release is AndroidDirectRelease) {
"The system installer opened the update confirmation. If you closed it, open it again without downloading the APK again."
} else {
"The system installer opened the update confirmation."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (release is AndroidDirectRelease) {
Button(
onClick = { requestInstall(release) },
enabled = !installing,
) {
Text("Open installer again")
}
}
}
is AppUpdateInstallState.Installed -> Text(
"The update was installed. Restart nati.ve 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 @@ -432,7 +432,7 @@
"ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppCoroutineResult.kt": "a0d6f140ffa50d1def77e0734b4ceae63855eed9766e2f3f82e82f2307ddc8f7",
"ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppNavigationState.kt": "115b9a0b5d360fae4f0738d9e16e1077d969ca4bb5d3c136401d7156adde431c",
"ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateChannels.kt": "f8aef5ec39978ef0d80ff6cbab00a9a4af34d73c2f3a759eb483a5cadfb8170a",
"ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateSettings.kt": "f41c20a9e0a91d917c746998ffa1e0061af1f7086eb55aedf495eb2604fbc71f",
"ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateSettings.kt": "68513029bb93f42910cefb81a3007d472ab2313845d6565bae296622d95dad86",
"ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspaceNavigationMemory.kt": "2a48ec4fda7ca47657253891e880a4169b16dbc83c197808532a92169961d569",
"ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt": "e2e9e8f823353180f0aaa7d4a5c4fc824591f0878845cfc6d19a0210645342fc",
"ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePresentation.kt": "e5655f80ca80cdb88ac49a54554e7d4cf684293efb2af4e291f0ef4b34529594",
Expand Down