diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt index bbbac1b16..07e84afaf 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt @@ -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 @@ -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 @@ -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, @@ -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, @@ -782,15 +780,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/main/kotlin/dev/obiente/nextcloudnative/AndroidUpdateInstaller.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidUpdateInstaller.kt new file mode 100644 index 000000000..5e3099237 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidUpdateInstaller.kt @@ -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, +): 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 +} 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..a65830abd --- /dev/null +++ b/changes/unreleased/android-update-installer-retry.md @@ -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. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateSettings.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateSettings.kt index 5d17220a7..73287a629 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateSettings.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateSettings.kt @@ -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, diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 2e0043272..b33802009 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -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",