Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ jobs:
with:
gradle-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}

- name: Copy debug.keystore
run: |
mkdir -p /home/runner/.config/.android/
cp .github/debug.keystore /home/runner/.config/.android/debug.keystore

- name: Run unit tests
run: |
./gradlew test --stacktrace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ public class InstallConstraints private constructor(
* If constraints are met earlier, session will be committed immediately.
*/
public data class Retry(public val retries: Int) : TimeoutStrategy {
init {
require(retries > 0) { "Retries count must be greater than zero, but was $retries" }
}
private companion object {
private const val serialVersionUID = -8122854334695670099L
}
Expand Down Expand Up @@ -186,6 +189,10 @@ public class InstallConstraints private constructor(
*/
public class Builder(private val timeoutMillis: Long) {

init {
require(timeoutMillis >= 0) { "Timeout cannot be negative, but was $timeoutMillis" }
}

@RequiresApi(Build.VERSION_CODES.O)
public constructor(timeout: java.time.Duration) : this(timeout.toMillis())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,13 +440,7 @@ public class InstallParameters private constructor(
*/
@SuppressLint("NewApi")
public fun build(): InstallParameters {
val pluginContainer = AckpinePluginContainer.from(plugins)
val pluginInstances = pluginContainer
.getPlugins()
.map { (pluginClass, _) -> AckpinePluginCache.get(pluginClass) }
for (plugin in pluginInstances) {
plugin.apply(this)
}
applyPlugins()
return InstallParameters(
ReadOnlyApkList(apks),
installerType,
Expand All @@ -459,10 +453,22 @@ public class InstallParameters private constructor(
constraints,
requestUpdateOwnership,
packageSource,
pluginContainer
AckpinePluginContainer.from(plugins)
)
}

private fun applyPlugins() {
val appliedPlugins = mutableSetOf<Class<out AckpinePlugin<*>>>()
var pluginsToApply: List<Class<out AckpinePlugin<*>>>
do {
pluginsToApply = plugins.keys.filterNot(appliedPlugins::contains)
for (pluginClass in pluginsToApply) {
AckpinePluginCache.get(pluginClass).apply(this)
appliedPlugins += pluginClass
}
} while (pluginsToApply.isNotEmpty())
}

private fun applyInstallerTypeInvariants(value: InstallerType) = when {
!isPackageInstallerApiAvailable() -> InstallerType.INTENT_BASED
apks.size > 1 && isPackageInstallerApiAvailable() -> InstallerType.SESSION_BASED
Expand Down Expand Up @@ -530,8 +536,8 @@ private class ReadOnlyApkList(private val apkList: ApkList) : ApkList by apkList

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ReadOnlyApkList) return false
return apkList == other.apkList
if (other !is ApkList) return false
return apkList == other
}

override fun hashCode() = apkList.hashCode()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import java.util.UUID
public interface PackageUninstaller {

/**
* Creates an uninstall session with provided [parameters].The returned session is in
* Creates an uninstall session with provided [parameters]. The returned session is in
* [pending][Session.State.Pending] state.
*
* @param parameters an instance of [UninstallParameters] which configures the uninstall session.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,26 @@ public class UninstallParameters private constructor(
* Constructs a new instance of [UninstallParameters].
*/
public fun build(): UninstallParameters {
val pluginContainer = AckpinePluginContainer.from(plugins)
val pluginInstances = pluginContainer
.getPlugins()
.map { (pluginClass, _) -> AckpinePluginCache.get(pluginClass) }
for (plugin in pluginInstances) {
plugin.apply(this)
}
return UninstallParameters(packageName, uninstallerType, confirmation, notificationData, pluginContainer)
applyPlugins()
return UninstallParameters(
packageName,
uninstallerType,
confirmation,
notificationData,
AckpinePluginContainer.from(plugins)
)
}

private fun applyPlugins() {
val appliedPlugins = mutableSetOf<Class<out AckpinePlugin<*>>>()
var pluginsToApply: List<Class<out AckpinePlugin<*>>>
do {
pluginsToApply = plugins.keys.filterNot(appliedPlugins::contains)
for (pluginClass in pluginsToApply) {
AckpinePluginCache.get(pluginClass).apply(this)
appliedPlugins += pluginClass
}
} while (pluginsToApply.isNotEmpty())
}

private fun applyUninstallerTypeInvariants(value: UninstallerType) = when {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2026 Ilya Fomichev
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package ru.solrudev.ackpine.installer.parameters

import java.time.Duration
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.time.Duration.Companion.milliseconds

class InstallConstraintsTest {

@Test
fun negativeTimeoutThrows() {
assertFailsWith<IllegalArgumentException> {
InstallConstraints.Builder(-1L)
}
assertFailsWith<IllegalArgumentException> {
InstallConstraints.Builder(Duration.ofMillis(-1))
}
assertFailsWith<IllegalArgumentException> {
InstallConstraints.gentleUpdate(-1L)
}
assertFailsWith<IllegalArgumentException> {
InstallConstraints.gentleUpdate(Duration.ofMillis(-1))
}
assertFailsWith<IllegalArgumentException> {
InstallConstraints.gentleUpdate((-1).milliseconds)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import ru.solrudev.ackpine.DelicateAckpineApi
import ru.solrudev.ackpine.SdkInt
import ru.solrudev.ackpine.exceptions.SplitPackagesNotSupportedException
import ru.solrudev.ackpine.plugability.AckpinePlugin
import ru.solrudev.ackpine.plugability.ChainedPlugin
import ru.solrudev.ackpine.plugability.ChainedTestPlugin
import ru.solrudev.ackpine.plugability.TestParameterlessPlugin
import ru.solrudev.ackpine.plugability.TestPlugin
import ru.solrudev.ackpine.resources.ResolvableString
Expand Down Expand Up @@ -167,6 +169,20 @@ class InstallParametersBuilderTest {
assertEquals("applied-by-plugin", parameters.name)
}

@Test
fun chainedPluginIsAppliedDuringBuild() {
val parameters = InstallParameters.Builder(Uri.EMPTY)
.usePlugin(ChainedTestPlugin::class.java)
.build()
val expectedPlugins = mapOf<Class<out AckpinePlugin<*>>, AckpinePlugin.Parameters>(
ChainedTestPlugin::class.java to AckpinePlugin.Parameters.None,
ChainedPlugin::class.java to AckpinePlugin.Parameters.None,
TestParameterlessPlugin::class.java to AckpinePlugin.Parameters.None
)
assertEquals("applied-by-plugin", parameters.name)
assertEquals(expectedPlugins, parameters.pluginContainer.getPlugins())
}

@Test
fun pluginParametersArePreserved() {
val parameters = InstallParameters.Builder(Uri.EMPTY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,30 @@ class TestParameterlessPlugin : AckpinePlugin<AckpinePlugin.Parameters.None> {
override fun apply(builder: UninstallParameters.Builder) {
builder.setPackageName("applied-by-plugin")
}
}

class ChainedTestPlugin : AckpinePlugin<AckpinePlugin.Parameters.None> {

override val id = "chained-test-plugin"

override fun apply(builder: InstallParameters.Builder) {
builder.usePlugin(ChainedPlugin::class.java)
}

override fun apply(builder: UninstallParameters.Builder) {
builder.usePlugin(ChainedPlugin::class.java)
}
}

class ChainedPlugin : AckpinePlugin<AckpinePlugin.Parameters.None> {

override val id = "chained-plugin"

override fun apply(builder: InstallParameters.Builder) {
builder.usePlugin(TestParameterlessPlugin::class.java)
}

override fun apply(builder: UninstallParameters.Builder) {
builder.usePlugin(TestParameterlessPlugin::class.java)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package ru.solrudev.ackpine.uninstaller.parameters

import ru.solrudev.ackpine.SdkInt
import ru.solrudev.ackpine.plugability.AckpinePlugin
import ru.solrudev.ackpine.plugability.ChainedPlugin
import ru.solrudev.ackpine.plugability.ChainedTestPlugin
import ru.solrudev.ackpine.plugability.TestParameterlessPlugin
import ru.solrudev.ackpine.plugability.TestPlugin
import ru.solrudev.ackpine.session.parameters.Confirmation
Expand Down Expand Up @@ -80,6 +82,20 @@ class UninstallParametersBuilderTest {
assertEquals("applied-by-plugin", parameters.packageName)
}

@Test
fun chainedPluginIsAppliedDuringBuild() {
val parameters = UninstallParameters.Builder("com.example")
.usePlugin(ChainedTestPlugin::class.java)
.build()
val expectedPlugins = mapOf<Class<out AckpinePlugin<*>>, AckpinePlugin.Parameters>(
ChainedTestPlugin::class.java to AckpinePlugin.Parameters.None,
ChainedPlugin::class.java to AckpinePlugin.Parameters.None,
TestParameterlessPlugin::class.java to AckpinePlugin.Parameters.None
)
assertEquals("applied-by-plugin", parameters.packageName)
assertEquals(expectedPlugins, parameters.pluginContainer.getPlugins())
}

@Test
fun pluginParametersArePreserved() {
val parameters = UninstallParameters.Builder("com.example")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import ru.solrudev.ackpine.impl.helpers.launchConfirmation
import ru.solrudev.ackpine.impl.installer.activity.IntentBasedInstallActivity
import ru.solrudev.ackpine.impl.installer.session.helpers.PROGRESS_MAX
import ru.solrudev.ackpine.impl.installer.session.helpers.copyTo
import ru.solrudev.ackpine.impl.installer.session.helpers.openAssetFileDescriptor
import ru.solrudev.ackpine.impl.installer.session.helpers.openAssetFileDescriptorWithSize
import ru.solrudev.ackpine.impl.session.AbstractProgressSession
import ru.solrudev.ackpine.installer.InstallFailure
import ru.solrudev.ackpine.session.Progress
Expand Down Expand Up @@ -142,7 +142,7 @@ internal class IntentBasedInstallSession internal constructor(
}
file.parentFile?.mkdirs()
file.createNewFile()
val afd = context.openAssetFileDescriptor(apk, cancellationSignal)
val afd = context.openAssetFileDescriptorWithSize(apk, cancellationSignal)
?: throw NullPointerException("AssetFileDescriptor was null: $apk")
afd.createInputStream().buffered().use { apkStream ->
val outputStream = file.outputStream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import ru.solrudev.ackpine.impl.installer.CommitProgressValueHolder
import ru.solrudev.ackpine.impl.installer.receiver.PackageInstallerStatusReceiver
import ru.solrudev.ackpine.impl.installer.session.helpers.PROGRESS_MAX
import ru.solrudev.ackpine.impl.installer.session.helpers.copyTo
import ru.solrudev.ackpine.impl.installer.session.helpers.openAssetFileDescriptor
import ru.solrudev.ackpine.impl.installer.session.helpers.openAssetFileDescriptorWithSize
import ru.solrudev.ackpine.impl.receiver.SystemPackageInstallerStatusReceiver
import ru.solrudev.ackpine.impl.services.PackageInstallerService
import ru.solrudev.ackpine.impl.session.AbstractProgressSession
Expand Down Expand Up @@ -449,7 +449,7 @@ internal class SessionBasedInstallSession internal constructor(
val tag = "SessionBasedInstallSession.writeApks"
val assetFileDescriptors = apks
.mapCatchingFirst { uri ->
context.openAssetFileDescriptor(uri, cancellationSignal)
context.openAssetFileDescriptorWithSize(uri, cancellationSignal)
?: throw NullPointerException("AssetFileDescriptor was null: $uri")
}
.getOrElse { failure ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ import android.os.CancellationSignal
import android.provider.OpenableColumns

@JvmSynthetic
internal fun Context.openAssetFileDescriptor(uri: Uri, signal: CancellationSignal): AssetFileDescriptor? {
internal fun Context.openAssetFileDescriptorWithSize(uri: Uri, signal: CancellationSignal): AssetFileDescriptor? {
val afd = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
contentResolver.openAssetFileDescriptor(uri, "r", signal)
} else {
contentResolver.openAssetFileDescriptor(uri, "r")
}
if (afd == null || afd.declaredLength != -1L) {
if (afd == null || afd.declaredLength >= 0) {
return afd
}
return AssetFileDescriptor(afd.parcelFileDescriptor, afd.startOffset, contentResolver.getSize(uri, signal))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,14 @@ internal abstract class SystemPackageInstallerStatusReceiver<F : Failure> protec
Log.e(tag, null, exception)
},
block = { session ->
session ?: return@handleResult
handlePackageInstallerStatus(session, ackpineSessionId, intent, context, pendingResult)
})
}
)
}

private fun handlePackageInstallerStatus(
session: CompletableSession<F>?,
session: CompletableSession<F>,
ackpineSessionId: UUID,
intent: Intent,
context: Context,
Expand All @@ -95,7 +97,7 @@ internal abstract class SystemPackageInstallerStatusReceiver<F : Failure> protec
status == PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirmationIntent = intent.getParcelableExtraCompat<Intent>(Intent.EXTRA_INTENT)
if (confirmationIntent == null) {
session?.completeExceptionally(
session.completeExceptionally(
IllegalStateException("$tag: confirmationIntent was null.")
)
return
Expand Down Expand Up @@ -148,7 +150,7 @@ internal abstract class SystemPackageInstallerStatusReceiver<F : Failure> protec
}

private fun handleSessionResult(
session: CompletableSession<F>?,
session: CompletableSession<F>,
status: Int,
message: String?,
intent: Intent
Expand All @@ -157,11 +159,11 @@ internal abstract class SystemPackageInstallerStatusReceiver<F : Failure> protec
PackageInstaller.STATUS_SUCCESS -> Session.State.Succeeded
else -> Session.State.Failed(getFailure(intent, status, message))
}
session?.complete(result)
session.complete(result)
}

private fun handlePreapprovalResult(
session: CompletableSession<F>?,
session: CompletableSession<F>,
status: Int,
packageInstallerStatus: PackageInstallerStatus?,
message: String?,
Expand Down
Loading
Loading