diff --git a/README.md b/README.md index 056c0f8b7..2842fd2fd 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Ackpine depends on Jetpack libraries, so it's necessary to declare the `google() ```kotlin dependencies { - val ackpineVersion = "0.9.2" + val ackpineVersion = "0.9.3" implementation("ru.solrudev.ackpine:ackpine-core:$ackpineVersion") // optional - Kotlin extensions and Coroutines support diff --git a/ackpine-resources/src/main/kotlin/ru/solrudev/ackpine/resources/ResolvableString.kt b/ackpine-resources/src/main/kotlin/ru/solrudev/ackpine/resources/ResolvableString.kt index 3de15217b..875577c4f 100644 --- a/ackpine-resources/src/main/kotlin/ru/solrudev/ackpine/resources/ResolvableString.kt +++ b/ackpine-resources/src/main/kotlin/ru/solrudev/ackpine/resources/ResolvableString.kt @@ -162,6 +162,7 @@ public sealed interface ResolvableString : Serializable { private data object Empty : ResolvableString { private const val serialVersionUID = 5194188194930148316L override fun resolve(context: Context): String = "" + private fun readResolve(): Any = Empty } private data class Raw(val value: String) : ResolvableString { diff --git a/ackpine-splits/api/ackpine-splits.api b/ackpine-splits/api/ackpine-splits.api index 6c9f524f1..623b8f7e5 100644 --- a/ackpine-splits/api/ackpine-splits.api +++ b/ackpine-splits/api/ackpine-splits.api @@ -4,6 +4,8 @@ public final class ru/solrudev/ackpine/ZippedFileProvider : android/content/Cont public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V public fun delete (Landroid/net/Uri;Ljava/lang/String;[Ljava/lang/String;)I public fun getType (Landroid/net/Uri;)Ljava/lang/String; + public static final fun getUriForZipEntry (Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri; + public static final fun getUriForZipEntry (Ljava/io/File;Ljava/lang/String;)Landroid/net/Uri; public static final fun getUriForZipEntry (Ljava/lang/String;Ljava/lang/String;)Landroid/net/Uri; public fun insert (Landroid/net/Uri;Landroid/content/ContentValues;)Landroid/net/Uri; public static final fun isZippedFileProviderUri (Landroid/net/Uri;)Z @@ -21,6 +23,8 @@ public final class ru/solrudev/ackpine/ZippedFileProvider : android/content/Cont } public final class ru/solrudev/ackpine/ZippedFileProvider$Companion { + public final fun getUriForZipEntry (Landroid/net/Uri;Ljava/lang/String;)Landroid/net/Uri; + public final fun getUriForZipEntry (Ljava/io/File;Ljava/lang/String;)Landroid/net/Uri; public final fun getUriForZipEntry (Ljava/lang/String;Ljava/lang/String;)Landroid/net/Uri; public final fun isZippedFileProviderUri (Landroid/net/Uri;)Z } diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/ZippedFileProvider.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/ZippedFileProvider.kt index b313450df..ab5a872aa 100644 --- a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/ZippedFileProvider.kt +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/ZippedFileProvider.kt @@ -26,28 +26,22 @@ import android.content.res.AssetFileDescriptor import android.database.Cursor import android.database.MatrixCursor import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.CancellationSignal import android.os.ParcelFileDescriptor import android.provider.OpenableColumns import android.webkit.MimeTypeMap -import androidx.annotation.RequiresApi import androidx.core.net.toUri -import ru.solrudev.ackpine.helpers.closeWithException -import ru.solrudev.ackpine.helpers.entries -import ru.solrudev.ackpine.helpers.toFile +import ru.solrudev.ackpine.io.ZipEntryStream import ru.solrudev.ackpine.plugin.AckpinePlugin import ru.solrudev.ackpine.plugin.AckpinePluginRegistry import java.io.File -import java.io.FileInputStream import java.io.FileNotFoundException import java.io.FileOutputStream +import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.util.concurrent.Executor -import java.util.zip.ZipFile -import java.util.zip.ZipInputStream /** * [ContentProvider] which allows to open files inside of ZIP archives. @@ -201,71 +195,9 @@ public class ZippedFileProvider : ContentProvider() { private fun openZipEntryStream(uri: Uri, signal: CancellationSignal?): ZipEntryStream { val zipFileUri = zipFileUri(uri) - val file = context?.let { context -> zipFileUri.toFile(context, signal) } - if (file?.canRead() == true) { - val zipFile = ZipFile(file) - return try { - val zipEntry = zipFile.getEntry(uri.encodedQuery) - ZipEntryStream(zipFile.getInputStream(zipEntry), zipEntry.size, zipFile) - } catch (throwable: Throwable) { - zipFile.closeWithException(throwable) - throw throwable - } - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return openZipEntryStreamApi26(zipFileUri, uri.encodedQuery, signal) - } - return openZipEntryStreamUsingZipInputStream(zipFileUri, uri.encodedQuery, signal) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun openZipEntryStreamApi26( - zipFileUri: Uri, - zipEntryName: String?, - signal: CancellationSignal? - ): ZipEntryStream { - var fd: ParcelFileDescriptor? = null - var fileInputStream: FileInputStream? = null - val zipFile: org.apache.commons.compress.archivers.zip.ZipFile - try { - fd = context?.contentResolver?.openFileDescriptor(zipFileUri, "r", signal) - ?: throw NullPointerException("ParcelFileDescriptor was null: $zipFileUri") - fileInputStream = FileInputStream(fd.fileDescriptor) - zipFile = org.apache.commons.compress.archivers.zip.ZipFile.builder() - .setSeekableByteChannel(fileInputStream.channel) - .get() - } catch (throwable: Throwable) { - fd?.closeWithException(throwable) - fileInputStream?.closeWithException(throwable) - throwable.printStackTrace() - return openZipEntryStreamUsingZipInputStream(zipFileUri, zipEntryName, signal) - } - try { - val zipEntry = zipFile.getEntry(zipEntryName) - return ZipEntryStream(zipFile.getInputStream(zipEntry), zipEntry.size, zipFile, fileInputStream, fd) - } catch (throwable: Throwable) { - fd.closeWithException(throwable) - fileInputStream.closeWithException(throwable) - zipFile.closeWithException(throwable) - throw throwable - } - } - - private fun openZipEntryStreamUsingZipInputStream( - zipFileUri: Uri, - zipEntryName: String?, - signal: CancellationSignal? - ): ZipEntryStream { - val zipStream = ZipInputStream(context?.contentResolver?.openInputStream(zipFileUri)) - val zipEntry = try { - zipStream.entries() - .onEach { signal?.throwIfCanceled() } - .first { it.name == zipEntryName } - } catch (throwable: Throwable) { - zipStream.closeWithException(throwable) - throw throwable - } - return ZipEntryStream(zipStream, zipEntry.size) + val zipEntryName = uri.encodedQuery + return ZipEntryStream.open(zipFileUri, zipEntryName.orEmpty(), context!!, signal) + ?: throw IOException("Zip entry $zipEntryName not found at $uri") } private fun zipFileUri(uri: Uri): Uri { @@ -360,6 +292,28 @@ public class ZippedFileProvider : ContentProvider() { .encodedQuery(zipEntryName) .build() } + + /** + * Creates an [Uri] for a ZIP entry. + * + * @param file a ZIP file containing [zip entry][zipEntryName]. + * @param zipEntryName name of the ZIP entry inside of the ZIP archive. + */ + @JvmStatic + public fun getUriForZipEntry(file: File, zipEntryName: String): Uri { + return getUriForZipEntry(file.absolutePath, zipEntryName) + } + + /** + * Creates an [Uri] for a ZIP entry. + * + * @param uri [Uri] pointing to a ZIP file containing [zip entry][zipEntryName]. + * @param zipEntryName name of the ZIP entry inside of the ZIP archive. + */ + @JvmStatic + public fun getUriForZipEntry(uri: Uri, zipEntryName: String): Uri { + return getUriForZipEntry(uri.toString(), zipEntryName) + } } } @@ -371,37 +325,4 @@ private object ZippedFileProviderPlugin : AckpinePlugin { override fun setExecutor(executor: Executor) { this.executor = executor } -} - -private class ZipEntryStream( - private val inputStream: InputStream, - val size: Long, - private vararg var resources: AutoCloseable -) : InputStream() { - - override fun read(): Int = inputStream.read() - override fun available(): Int = inputStream.available() - override fun markSupported(): Boolean = inputStream.markSupported() - override fun mark(readlimit: Int) = inputStream.mark(readlimit) - override fun read(b: ByteArray?): Int = inputStream.read(b) - override fun read(b: ByteArray?, off: Int, len: Int): Int = inputStream.read(b, off, len) - override fun reset() = inputStream.reset() - override fun skip(n: Long): Long = inputStream.skip(n) - - override fun close() { - for (resource in resources) { - runCatching { resource.close() } - } - resources = emptyArray() - inputStream.close() - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ZipEntryStream) return false - return inputStream == other.inputStream - } - - override fun hashCode(): Int = inputStream.hashCode() - override fun toString(): String = inputStream.toString() } \ No newline at end of file diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/helpers/UriHelpers.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/helpers/UriHelpers.kt index 51fd2173d..990550271 100644 --- a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/helpers/UriHelpers.kt +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/helpers/UriHelpers.kt @@ -19,7 +19,6 @@ package ru.solrudev.ackpine.helpers import android.content.ContentResolver import android.content.Context import android.net.Uri -import android.os.Build import android.os.CancellationSignal import android.os.Environment import android.os.Process @@ -28,18 +27,14 @@ import java.io.File import java.io.FileNotFoundException @JvmSynthetic -internal fun Uri.toFile(context: Context, signal: CancellationSignal? = null): File { - if (scheme == ContentResolver.SCHEME_FILE) { - return File(requireNotNull(path) { "Uri path is null: $this" }) +internal fun Context.getFileFromUri(uri: Uri, signal: CancellationSignal? = null): File { + if (uri.scheme == ContentResolver.SCHEME_FILE) { + return File(requireNotNull(uri.path) { "Uri path is null: $uri" }) } try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - context.contentResolver.openFileDescriptor(this, "r", signal) - } else { - context.contentResolver.openFileDescriptor(this, "r") - }.use { fileDescriptor -> + contentResolver.openFileDescriptor(uri, "r", signal).use { fileDescriptor -> if (fileDescriptor == null) { - throw NullPointerException("ParcelFileDescriptor was null: $this") + throw NullPointerException("ParcelFileDescriptor was null: $uri") } val path = "/proc/${Process.myPid()}/fd/${fileDescriptor.fd}" val canonicalPath = File(path).canonicalPath.let { canonicalPath -> @@ -50,7 +45,7 @@ internal fun Uri.toFile(context: Context, signal: CancellationSignal? = null): F } } if (canonicalPath == path) { - return tryFileFromExternalDocumentUri(context, this) ?: File("") + return tryGetFileFromExternalDocumentUri(this, uri) ?: File("") } return File(canonicalPath) } @@ -59,8 +54,8 @@ internal fun Uri.toFile(context: Context, signal: CancellationSignal? = null): F } } -private fun tryFileFromExternalDocumentUri(context: Context, uri: Uri): File? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT || !DocumentsContract.isDocumentUri(context, uri)) { +private fun tryGetFileFromExternalDocumentUri(context: Context, uri: Uri): File? { + if (!DocumentsContract.isDocumentUri(context, uri)) { return null } if (uri.authority != "com.android.externalstorage.documents") { diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/helpers/NonClosingInputStream.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/io/NonClosingInputStream.kt similarity index 95% rename from ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/helpers/NonClosingInputStream.kt rename to ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/io/NonClosingInputStream.kt index 114304902..55fbf88c1 100644 --- a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/helpers/NonClosingInputStream.kt +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/io/NonClosingInputStream.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Ilya Fomichev + * Copyright (C) 2023-2024 Ilya Fomichev * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package ru.solrudev.ackpine.splits.helpers +package ru.solrudev.ackpine.io import androidx.annotation.RestrictTo import java.io.InputStream diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/io/ToByteBuffer.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/io/ToByteBuffer.kt new file mode 100644 index 000000000..ba4a3606f --- /dev/null +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/io/ToByteBuffer.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 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.io + +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.nio.ByteBuffer + +@JvmSynthetic +internal fun InputStream.toByteBuffer(): ByteBuffer { + val buffer = ByteArrayOutputStream() + copyTo(buffer) + return ByteBuffer.wrap(buffer.toByteArray()) +} \ No newline at end of file diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/io/ZipEntryStream.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/io/ZipEntryStream.kt new file mode 100644 index 000000000..c760e5e0b --- /dev/null +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/io/ZipEntryStream.kt @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2024 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.io + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import androidx.annotation.RequiresApi +import ru.solrudev.ackpine.helpers.closeWithException +import ru.solrudev.ackpine.helpers.entries +import ru.solrudev.ackpine.helpers.getFileFromUri +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream + +internal class ZipEntryStream private constructor( + private val inputStream: InputStream, + val size: Long, + private vararg var resources: AutoCloseable +) : InputStream() { + + override fun read(): Int = inputStream.read() + override fun available(): Int = inputStream.available() + override fun markSupported(): Boolean = inputStream.markSupported() + override fun mark(readlimit: Int) = inputStream.mark(readlimit) + override fun read(b: ByteArray?): Int = inputStream.read(b) + override fun read(b: ByteArray?, off: Int, len: Int): Int = inputStream.read(b, off, len) + override fun reset() = inputStream.reset() + override fun skip(n: Long): Long = inputStream.skip(n) + + override fun close() { + for (resource in resources) { + runCatching { resource.close() } + } + resources = emptyArray() + inputStream.close() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ZipEntryStream) return false + return inputStream == other.inputStream + } + + override fun hashCode(): Int = inputStream.hashCode() + override fun toString(): String = inputStream.toString() + + internal companion object { + + @JvmSynthetic + internal fun open( + uri: Uri, + zipEntryName: String, + context: Context, + signal: CancellationSignal? = null + ): ZipEntryStream? { + val file = context.getFileFromUri(uri, signal) + return when { + file.canRead() -> openUsingZipFile(file, zipEntryName) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> openApi26(uri, zipEntryName, context, signal) + else -> openUsingZipInputStream(uri, zipEntryName, context, signal) + } + } + + private fun openUsingZipFile(file: File, zipEntryName: String): ZipEntryStream? { + val zipFile = ZipFile(file) + try { + val zipEntry = zipFile.getEntry(zipEntryName) + if (zipEntry == null) { + zipFile.close() + return null + } + return ZipEntryStream(zipFile.getInputStream(zipEntry), zipEntry.size, zipFile) + } catch (throwable: Throwable) { + zipFile.closeWithException(throwable) + throw throwable + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun openApi26( + uri: Uri, + zipEntryName: String, + context: Context, + signal: CancellationSignal? + ): ZipEntryStream? { + var fd: ParcelFileDescriptor? = null + var fileInputStream: FileInputStream? = null + val zipFile: org.apache.commons.compress.archivers.zip.ZipFile + try { + fd = context.contentResolver.openFileDescriptor(uri, "r", signal) + ?: throw NullPointerException("ParcelFileDescriptor was null: $uri") + fileInputStream = FileInputStream(fd.fileDescriptor) + zipFile = org.apache.commons.compress.archivers.zip.ZipFile.builder() + .setSeekableByteChannel(fileInputStream.channel) + .get() + } catch (throwable: Throwable) { + fd?.closeWithException(throwable) + fileInputStream?.closeWithException(throwable) + throwable.printStackTrace() + return openUsingZipInputStream(uri, zipEntryName, context, signal) + } + try { + val zipEntry = zipFile.getEntry(zipEntryName) + if (zipEntry == null) { + fd.close() + fileInputStream.close() + zipFile.close() + return null + } + return ZipEntryStream(zipFile.getInputStream(zipEntry), zipEntry.size, zipFile, fileInputStream, fd) + } catch (throwable: Throwable) { + fd.closeWithException(throwable) + fileInputStream.closeWithException(throwable) + zipFile.closeWithException(throwable) + throw throwable + } + } + + private fun openUsingZipInputStream( + uri: Uri, + zipEntryName: String, + context: Context, + signal: CancellationSignal? + ): ZipEntryStream? { + val zipStream = ZipInputStream(context.contentResolver.openInputStream(uri)) + val zipEntry = try { + zipStream.entries() + .onEach { signal?.throwIfCanceled() } + .firstOrNull { it.name == zipEntryName } + } catch (throwable: Throwable) { + zipStream.closeWithException(throwable) + throw throwable + } + if (zipEntry == null) { + zipStream.close() + return null + } + return ZipEntryStream(zipStream, zipEntry.size) + } + } +} \ No newline at end of file diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/Apk.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/Apk.kt index d9969d96d..033538951 100644 --- a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/Apk.kt +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/Apk.kt @@ -21,15 +21,18 @@ import android.net.Uri import androidx.core.content.FileProvider import ru.solrudev.ackpine.AckpineFileProvider import ru.solrudev.ackpine.ZippedFileProvider -import ru.solrudev.ackpine.helpers.toFile +import ru.solrudev.ackpine.helpers.entries +import ru.solrudev.ackpine.helpers.getFileFromUri +import ru.solrudev.ackpine.io.NonClosingInputStream.Companion.nonClosing +import ru.solrudev.ackpine.io.ZipEntryStream +import ru.solrudev.ackpine.io.toByteBuffer import ru.solrudev.ackpine.splits.Dpi.Companion.dpi -import ru.solrudev.ackpine.splits.helpers.NonClosingInputStream.Companion.nonClosing import ru.solrudev.ackpine.splits.helpers.deviceLocales import ru.solrudev.ackpine.splits.helpers.displayNameAndSize import ru.solrudev.ackpine.splits.helpers.isApk import ru.solrudev.ackpine.splits.helpers.localeFromSplitName +import ru.solrudev.ackpine.splits.parsing.ANDROID_MANIFEST_FILE_NAME import ru.solrudev.ackpine.splits.parsing.AndroidManifest -import ru.solrudev.ackpine.splits.parsing.androidManifest import java.io.File import java.io.InputStream import java.nio.ByteBuffer @@ -262,7 +265,7 @@ public sealed class Apk( */ @JvmStatic public fun fromUri(uri: Uri, context: Context): Apk? { - val file = uri.toFile(context) + val file = context.getFileFromUri(uri) if (file.canRead()) { return fromFile(file, uri) } @@ -271,12 +274,10 @@ public sealed class Apk( } val (displayName, size) = uri.displayNameAndSize(context) val name = displayName.substringAfterLast('/').substringBeforeLast('.') - context.contentResolver.openInputStream(uri).use { inputStream -> - inputStream ?: return null - val androidManifest = ZipInputStream(inputStream.nonClosing()).use { it.androidManifest() } - ?: return null - return createApkSplit(androidManifest, name, uri, size) - } + val androidManifest = ZipEntryStream + .open(uri, ANDROID_MANIFEST_FILE_NAME, context) + ?.use(InputStream::toByteBuffer) ?: return null + return createApkSplit(androidManifest, name, uri, size) } @JvmSynthetic @@ -286,7 +287,10 @@ public sealed class Apk( } val uri = ZippedFileProvider.getUriForZipEntry(zipPath, zipEntry.name) val name = zipEntry.name.substringAfterLast('/').substringBeforeLast('.') - val androidManifest = ZipInputStream(inputStream.nonClosing()).use { it.androidManifest() } ?: return null + val androidManifest = ZipInputStream(inputStream.nonClosing()).use { zipInputStream -> + zipInputStream.entries().firstOrNull { it.name == ANDROID_MANIFEST_FILE_NAME } ?: return null + zipInputStream.toByteBuffer() + } return createApkSplit(androidManifest, name, uri, zipEntry.size) } @@ -295,10 +299,11 @@ public sealed class Apk( return null } val name = file.name.substringAfterLast('/').substringBeforeLast('.') - ZipFile(file).use { zipFile -> - val androidManifest = zipFile.androidManifest() ?: return null - return createApkSplit(androidManifest, name, uri, file.length()) + val androidManifest = ZipFile(file).use { zipFile -> + val zipEntry = zipFile.getEntry(ANDROID_MANIFEST_FILE_NAME) ?: return null + zipFile.getInputStream(zipEntry).toByteBuffer() } + return createApkSplit(androidManifest, name, uri, file.length()) } private fun createApkSplit(manifestByteBuffer: ByteBuffer, name: String, uri: Uri, size: Long): Apk? { diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/ApkSplits.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/ApkSplits.kt index ba2caf7db..135de0870 100644 --- a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/ApkSplits.kt +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/ApkSplits.kt @@ -240,7 +240,7 @@ public object ApkSplits { ?.let { yield(ApkCompatibility(isPreferred = true, it)) } private suspend inline fun SequenceScope.yieldRemaining( - groupedSplits: MutableCollection> + groupedSplits: Collection> ) where T : Apk.ConfigSplit, T : Apk { for (apk in groupedSplits.flatten()) { yield(ApkCompatibility(isPreferred = false, apk)) diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/ZippedApkSplits.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/ZippedApkSplits.kt index df8234797..fa11e885a 100644 --- a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/ZippedApkSplits.kt +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/ZippedApkSplits.kt @@ -23,7 +23,7 @@ import android.os.ParcelFileDescriptor import androidx.annotation.RequiresApi import ru.solrudev.ackpine.helpers.closeWithException import ru.solrudev.ackpine.helpers.entries -import ru.solrudev.ackpine.helpers.toFile +import ru.solrudev.ackpine.helpers.getFileFromUri import java.io.File import java.io.FileInputStream import java.util.zip.ZipFile @@ -60,7 +60,7 @@ public object ZippedApkSplits { public fun getApksForUri(uri: Uri, context: Context): Sequence { val applicationContext = context.applicationContext // avoid capturing context into closure return closeableSequence { - val file = uri.toFile(applicationContext) + val file = applicationContext.getFileFromUri(uri) when { file.canRead() -> yieldAllUsingFile(file) Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> yieldAllApi26(applicationContext, uri) @@ -104,23 +104,17 @@ public object ZippedApkSplits { yieldAllUsingZipInputStream(context, uri) return } - try { - zipFile.entries - .asSequence() - .filterNot { isClosed } - .mapNotNull { zipEntry -> - zipFile.getInputStream(zipEntry).use { entryStream -> - entryStream.use() - Apk.fromZipEntry(uri.toString(), zipEntry, entryStream) - } + zipFile.entries + .asSequence() + .filterNot { isClosed } + .mapNotNull { zipEntry -> + zipFile.getInputStream(zipEntry).use { entryStream -> + entryStream.use() + Apk.fromZipEntry(uri.toString(), zipEntry, entryStream) } - .forEach { yield(it) } - } catch (throwable: Throwable) { - fd.closeWithException(throwable) - fileInputStream.closeWithException(throwable) - zipFile.closeWithException(throwable) - throw throwable - } + } + .forEach { yield(it) } + } private suspend inline fun CloseableSequenceScope.yieldAllUsingZipInputStream(context: Context, uri: Uri) { diff --git a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/parsing/AndroidManifestParser.kt b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/parsing/AndroidManifestParser.kt index b7df626ee..b4d0be683 100644 --- a/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/parsing/AndroidManifestParser.kt +++ b/ackpine-splits/src/main/kotlin/ru/solrudev/ackpine/splits/parsing/AndroidManifestParser.kt @@ -17,13 +17,10 @@ package ru.solrudev.ackpine.splits.parsing import com.android.apksig.internal.apk.AndroidBinXmlParser -import ru.solrudev.ackpine.helpers.entries -import java.io.ByteArrayOutputStream import java.nio.ByteBuffer -import java.util.zip.ZipFile -import java.util.zip.ZipInputStream -private const val ANDROID_MANIFEST_FILE_NAME = "AndroidManifest.xml" +@get:JvmSynthetic +internal const val ANDROID_MANIFEST_FILE_NAME = "AndroidManifest.xml" @JvmSynthetic internal fun AndroidManifest(androidManifest: ByteBuffer): AndroidManifest? { @@ -56,22 +53,4 @@ internal fun AndroidManifest(androidManifest: ByteBuffer): AndroidManifest? { return null } return AndroidManifest(manifest) -} - -@JvmSynthetic -internal fun ZipInputStream.androidManifest(): ByteBuffer? { - entries().firstOrNull { it.name == ANDROID_MANIFEST_FILE_NAME } ?: return null - val buffer = ByteArrayOutputStream() - copyTo(buffer) - return ByteBuffer.wrap(buffer.toByteArray()) -} - -@JvmSynthetic -internal fun ZipFile.androidManifest(): ByteBuffer? { - val androidManifestZipEntry = getEntry(ANDROID_MANIFEST_FILE_NAME) ?: return null - getInputStream(androidManifestZipEntry).use { entryStream -> - val buffer = ByteArrayOutputStream() - entryStream.copyTo(buffer) - return ByteBuffer.wrap(buffer.toByteArray()) - } } \ No newline at end of file diff --git a/build-logic/src/main/kotlin/ru/solrudev/ackpine/gradle/Constants.kt b/build-logic/src/main/kotlin/ru/solrudev/ackpine/gradle/Constants.kt index a626e302c..5f91495d0 100644 --- a/build-logic/src/main/kotlin/ru/solrudev/ackpine/gradle/Constants.kt +++ b/build-logic/src/main/kotlin/ru/solrudev/ackpine/gradle/Constants.kt @@ -22,13 +22,13 @@ public object Constants { public const val PACKAGE_NAME: String = "ru.solrudev.ackpine" public const val JDK_VERSION: Int = 17 public const val MIN_SDK: Int = 16 - public const val COMPILE_SDK: Int = 34 - public const val BUILD_TOOLS_VERSION: String = "34.0.0" + public const val COMPILE_SDK: Int = 35 + public const val BUILD_TOOLS_VERSION: String = "35.0.0" } public object SampleConstants { public const val PACKAGE_NAME: String = "ru.solrudev.ackpine.sample" public const val MIN_SDK: Int = 21 - public const val TARGET_SDK: Int = 34 + public const val TARGET_SDK: Int = 35 public val JAVA_VERSION: JavaVersion = JavaVersion.VERSION_17 } \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index a557d029e..c9739ccc1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,30 @@ Change Log ========== +Version 0.9.3 (2024-12-25) +-------------------------- + +### Dependencies + +- Updated KSP to 2.0.21-1.0.28. +- Updated Android Gradle Plugin to 8.7.3. +- Updated `apksig` to 8.7.3. +- Updated `binary-compatibility-validator` to 0.17.0. +- Updated `androidx.navigation` to 2.8.5 (sample apps dependency). +- Updated Guava to 33.4.0-android (sample apps dependency). + +### Bug fixes and improvements + +- Raise `compileSdk` to 35. +- Use random access when parsing APK on API level 26+ in `Apk.fromUri()`. This greatly improves performance for large APKs. +- Add `ZippedFileProvider.getUriForZipEntry()` overloads for `File` and `Uri`. +- Raise `targetSdk` for sample apps to 35. +- Proper support for edge-to-edge display in sample apps. + +### Public API changes + +- Added `getUriForZipEntry(File, String)` and `getUriForZipEntry(Uri, String)` to `ZippedFileProvider.Companion` and as static `ZippedFileProvider` methods. + Version 0.9.2 (2024-12-19) -------------------------- diff --git a/docs/index.md b/docs/index.md index be1d55a9b..e8d17fb3c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,7 +27,7 @@ Ackpine depends on Jetpack libraries, so it's necessary to declare the `google() ```kotlin dependencies { - val ackpineVersion = "0.9.2" + val ackpineVersion = "0.9.3" implementation("ru.solrudev.ackpine:ackpine-core:$ackpineVersion") // optional - Kotlin extensions and Coroutines support diff --git a/gradle/androidx.versions.toml b/gradle/androidx.versions.toml index cd20c08af..c26a7953e 100644 --- a/gradle/androidx.versions.toml +++ b/gradle/androidx.versions.toml @@ -1,6 +1,6 @@ [versions] lifecycle = "2.8.7" -navigation = "2.8.3" +navigation = "2.8.5" room = "2.6.1" concurrent = "1.2.0" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 123432926..a2e6521b7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -android-gradleplugin = "8.6.1" +android-gradleplugin = "8.7.3" kotlin = "2.0.21" -kotlin-ksp = "2.0.21-1.0.26" +kotlin-ksp = "2.0.21-1.0.28" [libraries] materialcomponents = { module = "com.google.android.material:material", version = "1.12.0" } @@ -9,13 +9,14 @@ plugin-gradleMavenPublish = { module = "com.vanniktech:gradle-maven-publish-plug plugin-agp = { module = "com.android.tools.build:gradle", version.ref = "android-gradleplugin" } plugin-kotlin-android = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } plugin-dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "2.0.0" } -plugin-binaryCompatibilityValidator = { module = "org.jetbrains.kotlinx.binary-compatibility-validator:org.jetbrains.kotlinx.binary-compatibility-validator.gradle.plugin", version = "0.16.3" } +plugin-binaryCompatibilityValidator = { module = "org.jetbrains.kotlinx.binary-compatibility-validator:org.jetbrains.kotlinx.binary-compatibility-validator.gradle.plugin", version = "0.17.0" } apache-commons-compress = { module = "org.apache.commons:commons-compress", version = "1.27.1" } apksig = { module = "com.android.tools.build:apksig", version.ref = "android-gradleplugin" } listenablefuture = { module = "com.google.guava:listenablefuture", version = "1.0" } -guava = { module = "com.google.guava:guava", version = "33.3.1-android" } +guava = { module = "com.google.guava:guava", version = "33.4.0-android" } viewbindingpropertydelegate = { module = "com.github.kirich1409:viewbindingpropertydelegate-noreflection", version = "1.5.9" } okhttp = { module = "com.squareup.okhttp3:okhttp", version = "4.12.0" } +insetter = { module = "dev.chrisbanes.insetter:insetter", version = "0.6.1" } [plugins] android-application = { id = "com.android.application", version.ref = "android-gradleplugin" } diff --git a/sample-api34/build.gradle.kts b/sample-api34/build.gradle.kts index ba3cb983b..5068648a9 100644 --- a/sample-api34/build.gradle.kts +++ b/sample-api34/build.gradle.kts @@ -38,4 +38,5 @@ dependencies { implementation(libs.materialcomponents) implementation(libs.okhttp) implementation(libs.viewbindingpropertydelegate) + implementation(libs.insetter) } \ No newline at end of file diff --git a/sample-api34/src/main/kotlin/ru/solrudev/ackpine/sample/updater/MainActivity.kt b/sample-api34/src/main/kotlin/ru/solrudev/ackpine/sample/updater/MainActivity.kt index 34682b348..f2888d6fc 100644 --- a/sample-api34/src/main/kotlin/ru/solrudev/ackpine/sample/updater/MainActivity.kt +++ b/sample-api34/src/main/kotlin/ru/solrudev/ackpine/sample/updater/MainActivity.kt @@ -18,12 +18,14 @@ package ru.solrudev.ackpine.sample.updater import android.animation.LayoutTransition import android.os.Bundle +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import by.kirich1409.viewbindingdelegate.viewBinding +import dev.chrisbanes.insetter.applyInsetter import kotlinx.coroutines.launch import ru.solrudev.ackpine.AssetFileProvider import ru.solrudev.ackpine.resources.ResolvableString @@ -38,6 +40,8 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) + enableEdgeToEdge() + applyInsets() setSupportActionBar(binding.toolbarMain) with(binding.cardMainInstall) { imageViewInstallIcon.setImageURI( @@ -60,6 +64,15 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { } } + private fun applyInsets() = binding.containerMain.applyInsetter { + type(statusBars = true, displayCutout = true) { + padding() + } + type(navigationBars = true) { + padding(horizontal = true) + } + } + private fun setProgress(progressData: Progress) = with(binding.cardMainInstall) { val progress = progressData.progress val max = progressData.max diff --git a/sample-java/build.gradle.kts b/sample-java/build.gradle.kts index 583bf4ea5..61053b668 100644 --- a/sample-java/build.gradle.kts +++ b/sample-java/build.gradle.kts @@ -35,4 +35,5 @@ dependencies { implementation(androidx.swiperefreshlayout) implementation(libs.materialcomponents) implementation(libs.guava) + implementation(libs.insetter) } \ No newline at end of file diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/MainActivity.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/MainActivity.java index 916af74e2..757828bab 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/MainActivity.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/MainActivity.java @@ -21,14 +21,18 @@ import android.content.Intent; import android.os.Bundle; +import androidx.activity.EdgeToEdge; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.WindowInsetsCompat; import androidx.navigation.NavController; import androidx.navigation.NavOptions; import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.ui.AppBarConfiguration; import androidx.navigation.ui.NavigationUI; +import dev.chrisbanes.insetter.Insetter; +import dev.chrisbanes.insetter.Side; import ru.solrudev.ackpine.sample.databinding.NavHostBinding; import ru.solrudev.ackpine.sample.install.InstallFragment; @@ -44,6 +48,8 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding = NavHostBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); + EdgeToEdge.enable(this); + applyInsets(); final NavController navController = getNavController(); NavigationUI.setupWithNavController(binding.toolbarNavHost, navController, appBarConfiguration); NavigationUI.setupWithNavController(binding.bottomNavigationViewNavHost, navController); @@ -58,6 +64,22 @@ protected void onNewIntent(@NonNull Intent intent) { maybeHandleInstallUri(intent); } + private void applyInsets() { + Insetter.builder() + .padding(WindowInsetsCompat.Type.statusBars()) + .padding(WindowInsetsCompat.Type.navigationBars(), Side.LEFT | Side.RIGHT) + .padding(WindowInsetsCompat.Type.displayCutout(), Side.LEFT | Side.RIGHT | Side.TOP) + .applyToView(binding.appBarLayoutNavHost); + Insetter.builder() + .padding(WindowInsetsCompat.Type.navigationBars(), Side.LEFT | Side.RIGHT) + .padding(WindowInsetsCompat.Type.displayCutout(), Side.LEFT | Side.RIGHT) + .applyToView(binding.contentNavHost); + Insetter.builder() + .padding(WindowInsetsCompat.Type.navigationBars(), Side.LEFT | Side.RIGHT | Side.BOTTOM) + .padding(WindowInsetsCompat.Type.displayCutout(), Side.LEFT | Side.RIGHT | Side.BOTTOM) + .applyToView(binding.bottomNavigationViewNavHost); + } + private void maybeHandleInstallUri(@NonNull Intent intent) { final var uri = intent.getData(); if (ACTION_VIEW.equals(intent.getAction()) && uri != null) { diff --git a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallFragment.java b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallFragment.java index 463d68dd5..19f6edf21 100644 --- a/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallFragment.java +++ b/sample-java/src/main/java/ru/solrudev/ackpine/sample/install/InstallFragment.java @@ -144,7 +144,9 @@ private void resetUriToInstall() { private void observeViewModel() { viewModel.getError().observe(getViewLifecycleOwner(), error -> { if (!error.isEmpty()) { - Snackbar.make(requireView(), error.resolve(requireContext()), Snackbar.LENGTH_LONG) + Snackbar.make(requireActivity().findViewById(R.id.content_nav_host), + error.resolve(requireContext()), + Snackbar.LENGTH_LONG) .setAnchorView(binding.fabInstall) .show(); viewModel.clearError(); diff --git a/sample-ktx/build.gradle.kts b/sample-ktx/build.gradle.kts index d4d4b32cb..b92073c10 100644 --- a/sample-ktx/build.gradle.kts +++ b/sample-ktx/build.gradle.kts @@ -35,4 +35,5 @@ dependencies { implementation(androidx.swiperefreshlayout) implementation(libs.materialcomponents) implementation(libs.viewbindingpropertydelegate) + implementation(libs.insetter) } \ No newline at end of file diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/MainActivity.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/MainActivity.kt index fdf33fd1e..d7167a9ff 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/MainActivity.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/MainActivity.kt @@ -19,6 +19,7 @@ package ru.solrudev.ackpine.sample import android.content.Intent import android.content.Intent.ACTION_VIEW import android.os.Bundle +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import androidx.navigation.NavController @@ -27,6 +28,7 @@ import androidx.navigation.navOptions import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupWithNavController import by.kirich1409.viewbindingdelegate.viewBinding +import dev.chrisbanes.insetter.applyInsetter import ru.solrudev.ackpine.sample.databinding.NavHostBinding import ru.solrudev.ackpine.sample.install.InstallFragment @@ -41,6 +43,8 @@ class MainActivity : AppCompatActivity(R.layout.nav_host) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) + enableEdgeToEdge() + applyInsets() val navController = navController binding.toolbarNavHost.setupWithNavController(navController, appBarConfiguration) binding.bottomNavigationViewNavHost.setupWithNavController(navController) @@ -54,6 +58,30 @@ class MainActivity : AppCompatActivity(R.layout.nav_host) { maybeHandleInstallUri(intent) } + private fun applyInsets() = with(binding) { + appBarLayoutNavHost.applyInsetter { + type(statusBars = true) { + padding() + } + type(navigationBars = true) { + padding(horizontal = true) + } + type(displayCutout = true) { + padding(horizontal = true, top = true) + } + } + contentNavHost.applyInsetter { + type(navigationBars = true, displayCutout = true) { + padding(horizontal = true) + } + } + bottomNavigationViewNavHost.applyInsetter { + type(navigationBars = true, displayCutout = true) { + padding(horizontal = true, bottom = true) + } + } + } + private fun maybeHandleInstallUri(intent: Intent) { val uri = intent.data if (intent.action == ACTION_VIEW && uri != null) { diff --git a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallFragment.kt b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallFragment.kt index 23944505e..d264f9b6a 100644 --- a/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallFragment.kt +++ b/sample-ktx/src/main/kotlin/ru/solrudev/ackpine/sample/install/InstallFragment.kt @@ -105,7 +105,11 @@ class InstallFragment : Fragment(R.layout.fragment_install) { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { uiState -> if (!uiState.error.isEmpty) { - Snackbar.make(requireView(), uiState.error.resolve(requireContext()), Snackbar.LENGTH_LONG) + Snackbar.make( + requireActivity().findViewById(R.id.content_nav_host), + uiState.error.resolve(requireContext()), + Snackbar.LENGTH_LONG + ) .setAnchorView(binding.fabInstall) .show() viewModel.clearError() diff --git a/version.properties b/version.properties index afffc4c58..ad7d74d6c 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ MAJOR_VERSION=0 MINOR_VERSION=9 -PATCH_VERSION=2 +PATCH_VERSION=3 SUFFIX= SNAPSHOT=false \ No newline at end of file