Skip to content

Commit 68147d6

Browse files
authored
Merge pull request #136 from solrudev/develop
0.15.1
2 parents 747fb7a + daa1131 commit 68147d6

16 files changed

Lines changed: 384 additions & 68 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Ackpine depends on Jetpack libraries, so it's also necessary to add the `google(
3535

3636
```toml
3737
[versions]
38-
ackpine = "0.15.0"
38+
ackpine = "0.15.1"
3939

4040
[libraries]
4141
ackpine-core = { module = "ru.solrudev.ackpine:ackpine-core", version.ref = "ackpine" }
@@ -76,7 +76,7 @@ ackpine = [
7676

7777
```kotlin
7878
dependencies {
79-
val ackpineVersion = "0.15.0"
79+
val ackpineVersion = "0.15.1"
8080
implementation("ru.solrudev.ackpine:ackpine-core:$ackpineVersion")
8181

8282
// optional - Kotlin extensions and Coroutines support
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright (C) 2025 Ilya Fomichev
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package ru.solrudev.ackpine
18+
19+
internal object SdkIntWrapper {
20+
@JvmSynthetic
21+
fun get() = try {
22+
SdkInt.get()
23+
} catch (_: NoClassDefFoundError) {
24+
throw NoClassDefFoundError(
25+
"Class ru.solrudev.ackpine.SdkInt was not found. " +
26+
"Make sure you have configured a dependency on ackpine-core module."
27+
)
28+
}
29+
}

ackpine-api/api-main/src/main/kotlin/ru/solrudev/ackpine/exceptions/Exceptions.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@
1616

1717
package ru.solrudev.ackpine.exceptions
1818

19-
import android.os.Build
19+
import ru.solrudev.ackpine.SdkIntWrapper
2020

2121
/**
2222
* Thrown if installation of split packages is not supported when creating session with split package is attempted.
2323
*/
2424
public class SplitPackagesNotSupportedException : IllegalArgumentException(
25-
"Split packages are not supported on current Android API level: ${Build.VERSION.SDK_INT}"
25+
"Split packages are not supported on current Android API level: ${SdkIntWrapper.get()}"
2626
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright (C) 2025 Ilya Fomichev
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package ru.solrudev.ackpine
18+
19+
/**
20+
* A replaceable stub for getting current SDK version.
21+
*/
22+
public object SdkInt {
23+
public fun get(): Int {
24+
throw NotImplementedError()
25+
}
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright (C) 2025 Ilya Fomichev
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package ru.solrudev.ackpine
18+
19+
import android.os.Build
20+
21+
@Suppress("Unused")
22+
internal object SdkInt {
23+
@JvmSynthetic
24+
@JvmName("get")
25+
internal fun get() = Build.VERSION.SDK_INT
26+
}

ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/activity/SessionCommitActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ internal abstract class SessionCommitActivity<F : Failure> protected constructor
141141
}
142142

143143
protected fun withCompletableSession(block: (CompletableSession<F>?) -> Unit) {
144-
ackpineSessionFuture.handleResult(block)
144+
ackpineSessionFuture.handleResult(block = block)
145145
}
146146

147147
private fun notifySessionCommitted() {

ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/IntentBasedInstallSession.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ internal class IntentBasedInstallSession internal constructor(
147147
apkFile.parentFile?.mkdirs()
148148
apkFile.createNewFile()
149149
val afd = context.openAssetFileDescriptor(apk, cancellationSignal)
150-
?: error("AssetFileDescriptor was null: $apk")
150+
?: throw NullPointerException("AssetFileDescriptor was null: $apk")
151151
afd.createInputStream().buffered().use { apkStream ->
152152
val outputStream = apkFile.outputStream()
153153
outputStream.buffered().use { bufferedOutputStream ->

ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/installer/session/SessionBasedInstallSession.kt

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,19 @@ import android.graphics.BitmapFactory
3333
import android.icu.util.ULocale
3434
import android.net.Uri
3535
import android.os.Build
36+
import android.os.CancellationSignal
3637
import android.os.Handler
3738
import android.os.OperationCanceledException
3839
import android.util.Log
3940
import androidx.annotation.ChecksSdkIntAtLeast
4041
import androidx.annotation.RequiresApi
4142
import androidx.annotation.RestrictTo
4243
import androidx.concurrent.futures.CallbackToFutureAdapter
44+
import ru.solrudev.ackpine.helpers.closeAllWithException
4345
import ru.solrudev.ackpine.helpers.closeWithException
4446
import ru.solrudev.ackpine.helpers.concurrent.handleResult
47+
import ru.solrudev.ackpine.helpers.getOrElse
48+
import ru.solrudev.ackpine.helpers.mapCatchingFirst
4549
import ru.solrudev.ackpine.helpers.use
4650
import ru.solrudev.ackpine.impl.database.dao.InstallConstraintsDao
4751
import ru.solrudev.ackpine.impl.database.dao.InstallPreapprovalDao
@@ -75,6 +79,7 @@ import ru.solrudev.ackpine.session.Session.State.Failed
7579
import ru.solrudev.ackpine.session.parameters.Confirmation
7680
import ru.solrudev.ackpine.session.parameters.NotificationData
7781
import java.util.UUID
82+
import java.util.concurrent.CancellationException
7883
import java.util.concurrent.Executor
7984
import java.util.concurrent.atomic.AtomicInteger
8085
import kotlin.random.Random
@@ -235,9 +240,9 @@ internal class SessionBasedInstallSession internal constructor(
235240
}
236241

237242
override fun doCleanup() {
238-
executor.execute(::abandonSession) // may be long if storage is under load
239-
clearPackageInstallerSessionCallback()
240243
isPreapprovalActive = false
244+
clearPackageInstallerSessionCallback()
245+
executor.execute(::abandonSession) // may be long if storage is under load
241246
}
242247

243248
@ChecksSdkIntAtLeast(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -403,7 +408,7 @@ internal class SessionBasedInstallSession internal constructor(
403408

404409
private fun writeApksToSession(sessionId: Int) {
405410
val session = packageInstaller.openSession(sessionId)
406-
session.writeApks().handleResult(
411+
session.writeApksAsync().handleResult(
407412
block = {
408413
try {
409414
session.close()
@@ -414,47 +419,75 @@ internal class SessionBasedInstallSession internal constructor(
414419
},
415420
onException = { exception ->
416421
session.closeWithException(exception)
417-
if (exception is OperationCanceledException) {
422+
if (exception is CancellationException) {
418423
cancel()
419424
} else {
420425
completeExceptionally(exception)
421426
}
422427
})
423428
}
424429

425-
private fun PackageInstallerService.Session.writeApks() = CallbackToFutureAdapter.getFuture { completer ->
430+
private fun PackageInstallerService.Session.writeApksAsync() = CallbackToFutureAdapter.getFuture { completer ->
431+
writeApks(completer)
432+
}
433+
434+
private fun PackageInstallerService.Session.writeApks(
435+
completer: CallbackToFutureAdapter.Completer<Unit>
436+
): String {
437+
val tag = "SessionBasedInstallSession.writeApks"
438+
val assetFileDescriptors = apks
439+
.mapCatchingFirst { uri ->
440+
context.openAssetFileDescriptor(uri, cancellationSignal)
441+
?: throw NullPointerException("AssetFileDescriptor was null: $uri")
442+
}
443+
.getOrElse { failure ->
444+
closeAllWithException(failure.partialResult, failure.exception)
445+
if (failure.exception is OperationCanceledException) {
446+
completer.setCancelled()
447+
} else {
448+
completer.setException(failure.exception)
449+
}
450+
return tag
451+
}
426452
val countdown = AtomicInteger(apks.size)
427453
val currentProgress = AtomicInteger(0)
428454
val progressMax = apks.size * PROGRESS_MAX
429-
apks.forEachIndexed { index, uri ->
430-
val afd = context.openAssetFileDescriptor(uri, cancellationSignal)
431-
?: error("AssetFileDescriptor was null: $uri")
455+
val sharedCancelSignal = CancellationSignal()
456+
cancellationSignal.setOnCancelListener {
457+
sharedCancelSignal.cancel()
458+
completer.setCancelled()
459+
}
460+
assetFileDescriptors.forEachIndexed { index, afd ->
432461
try {
433462
executor.execute {
434463
try {
435464
afd.use {
436-
writeApk(afd, index, currentProgress, progressMax)
465+
writeApk(afd, index, currentProgress, progressMax, sharedCancelSignal)
437466
if (countdown.decrementAndGet() == 0) {
438467
completer.set(Unit)
439468
}
440469
}
470+
} catch (_: OperationCanceledException) { // no-op
441471
} catch (throwable: Throwable) {
472+
sharedCancelSignal.cancel()
442473
completer.setException(throwable)
443474
}
444475
}
445476
} catch (exception: Exception) {
446477
afd.closeWithException(exception)
478+
sharedCancelSignal.cancel()
447479
completer.setException(exception)
448480
}
449481
}
450-
"SessionBasedInstallSession.writeApks"
482+
return tag
451483
}
452484

453485
private fun PackageInstallerService.Session.writeApk(
454486
afd: AssetFileDescriptor,
455487
index: Int,
456488
currentProgress: AtomicInteger,
457-
progressMax: Int
489+
progressMax: Int,
490+
cancellationSignal: CancellationSignal
458491
) = afd.createInputStream().use { apkStream ->
459492
requireNotNull(apkStream) { "APK $index InputStream was null." }
460493
val length = afd.declaredLength

ackpine-core/src/main/kotlin/ru/solrudev/ackpine/impl/session/AbstractSession.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ internal abstract class AbstractSession<F : Failure> protected constructor(
258258
}
259259

260260
private fun cleanup() {
261+
cancellationSignal.setOnCancelListener(null)
261262
doCleanup()
262263
context.getSystemService<NotificationManager>()?.cancel(id.toString(), notificationId)
263264
}

ackpine-runtime/src/main/kotlin/ru/solrudev/ackpine/helpers/CloseableHelpers.kt

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,37 @@ public fun AutoCloseable.closeWithException(cause: Throwable) {
4444
} catch (closeException: Throwable) {
4545
cause.addSuppressed(closeException)
4646
}
47-
}
47+
}
48+
49+
/**
50+
* Guarantees closing all [resources] and delivery of every failure through thrown exception.
51+
*/
52+
@RequiresApi(Build.VERSION_CODES.KITKAT)
53+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
54+
@JvmSynthetic
55+
public fun closeAll(resources: Iterable<AutoCloseable>) {
56+
resources
57+
.mapCatching { it.close() }
58+
.getOrThrow()
59+
}
60+
61+
/**
62+
* Guarantees closing all [resources] and delivery of every failure through the [cause] via its suppressed exceptions.
63+
*/
64+
@RequiresApi(Build.VERSION_CODES.KITKAT)
65+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
66+
@JvmSynthetic
67+
public fun closeAllWithException(resources: Iterable<AutoCloseable>, cause: Throwable) {
68+
resources
69+
.mapCatching { it.close() }
70+
.failureOrNull()
71+
?.let { failure -> cause.addSuppressed(failure.exception) }
72+
}
73+
74+
/**
75+
* Guarantees closing all [resources] and delivery of every failure through thrown exception.
76+
*/
77+
@RequiresApi(Build.VERSION_CODES.KITKAT)
78+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
79+
@JvmSynthetic
80+
public fun closeAll(vararg resources: AutoCloseable): Unit = closeAll(resources.asIterable())

0 commit comments

Comments
 (0)