Skip to content

Commit df181a6

Browse files
committed
Support importing split APKs
1 parent acc86b4 commit df181a6

File tree

5 files changed

+145
-46
lines changed

5 files changed

+145
-46
lines changed

app/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,5 @@ dependencies {
7575
implementation(libs.material.components)
7676
implementation(libs.libsu.core)
7777
implementation(libs.libsu.service)
78+
implementation(libs.zip4j)
7879
}

app/src/main/java/dev/zwander/installwithoptions/MainActivity.kt

+73-46
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package dev.zwander.installwithoptions
22

33
import android.content.Intent
4-
import android.content.pm.PackageParser
54
import android.os.Build
65
import android.os.Bundle
7-
import android.util.Log
86
import androidx.activity.SystemBarStyle
97
import androidx.activity.compose.rememberLauncherForActivityResult
108
import androidx.activity.compose.setContent
@@ -14,6 +12,7 @@ import androidx.appcompat.app.AppCompatActivity
1412
import androidx.compose.animation.AnimatedVisibility
1513
import androidx.compose.animation.fadeIn
1614
import androidx.compose.animation.fadeOut
15+
import androidx.compose.foundation.ExperimentalFoundationApi
1716
import androidx.compose.foundation.background
1817
import androidx.compose.foundation.border
1918
import androidx.compose.foundation.clickable
@@ -40,11 +39,15 @@ import androidx.compose.foundation.layout.size
4039
import androidx.compose.foundation.lazy.LazyColumn
4140
import androidx.compose.foundation.lazy.items
4241
import androidx.compose.foundation.shape.CircleShape
42+
import androidx.compose.material.icons.Icons
43+
import androidx.compose.material.icons.filled.Delete
4344
import androidx.compose.material3.AlertDialog
4445
import androidx.compose.material3.BottomAppBarDefaults
4546
import androidx.compose.material3.ButtonDefaults
4647
import androidx.compose.material3.Checkbox
4748
import androidx.compose.material3.CircularProgressIndicator
49+
import androidx.compose.material3.Icon
50+
import androidx.compose.material3.IconButton
4851
import androidx.compose.material3.MaterialTheme
4952
import androidx.compose.material3.OutlinedButton
5053
import androidx.compose.material3.OutlinedCard
@@ -70,7 +73,6 @@ import androidx.compose.ui.res.stringResource
7073
import androidx.compose.ui.text.font.FontWeight
7174
import androidx.compose.ui.text.style.TextDecoration
7275
import androidx.compose.ui.unit.dp
73-
import androidx.documentfile.provider.DocumentFile
7476
import dev.icerock.moko.mvvm.flow.compose.collectAsMutableState
7577
import dev.zwander.installwithoptions.components.Footer
7678
import dev.zwander.installwithoptions.data.DataModel
@@ -80,10 +82,10 @@ import dev.zwander.installwithoptions.data.rememberInstallOptions
8082
import dev.zwander.installwithoptions.data.rememberMutableOptions
8183
import dev.zwander.installwithoptions.ui.theme.InstallWithOptionsTheme
8284
import dev.zwander.installwithoptions.util.ElevatedPermissionHandler
85+
import dev.zwander.installwithoptions.util.handleIncomingUris
8386
import dev.zwander.installwithoptions.util.plus
8487
import dev.zwander.installwithoptions.util.rememberPackageInstaller
8588

86-
@Suppress("DEPRECATION")
8789
class MainActivity : AppCompatActivity() {
8890
private val permissionHandler by lazy {
8991
ElevatedPermissionHandler(
@@ -129,24 +131,13 @@ class MainActivity : AppCompatActivity() {
129131
}
130132

131133
private fun checkIntentForPackage(intent: Intent) {
132-
if (intent.type == "application/vnd.android.package-archive") {
133-
val selected = DataModel.selectedFiles.value.toMutableMap()
134-
val apkUri = intent.data ?: return
135-
val file = DocumentFile.fromSingleUri(this, apkUri) ?: return
136-
137-
val fd = contentResolver.openAssetFileDescriptor(apkUri, "r") ?: return
138-
val apkFile = PackageParser.parseApkLite(fd.fileDescriptor, file.name, 0)
139-
140-
val packageList = selected[apkFile.packageName] ?: listOf()
141-
142-
selected[apkFile.packageName] = packageList + file
143-
fd.close()
144-
145-
DataModel.selectedFiles.value = selected
134+
intent.data?.let {
135+
handleIncomingUris(listOf(it))
146136
}
147137
}
148138
}
149139

140+
@OptIn(ExperimentalFoundationApi::class)
150141
@Composable
151142
fun MainContent(modifier: Modifier = Modifier) {
152143
var selectedFiles by DataModel.selectedFiles.collectAsMutableState()
@@ -158,20 +149,7 @@ fun MainContent(modifier: Modifier = Modifier) {
158149
val context = LocalContext.current
159150
val fileSelector =
160151
rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenMultipleDocuments()) { uris ->
161-
val selected = selectedFiles.toMutableMap()
162-
163-
uris.forEach { uri ->
164-
val file = DocumentFile.fromSingleUri(context, uri) ?: return@forEach
165-
val fd = context.contentResolver.openAssetFileDescriptor(uri, "r") ?: return@forEach
166-
val apkFile = PackageParser.parseApkLite(fd.fileDescriptor, file.name, 0)
167-
168-
val packageList = selected[apkFile.packageName] ?: listOf()
169-
170-
selected[apkFile.packageName] = packageList + file
171-
fd.close()
172-
}
173-
174-
selectedFiles = selected
152+
context.handleIncomingUris(uris)
175153
}
176154
val options = (rememberInstallOptions() + rememberMutableOptions()).sortedBy {
177155
context.resources.getString(it.labelResource)
@@ -207,7 +185,11 @@ fun MainContent(modifier: Modifier = Modifier) {
207185
verticalAlignment = Alignment.CenterVertically,
208186
) {
209187
OutlinedButton(
210-
onClick = { fileSelector.launch(arrayOf("application/vnd.android.package-archive")) },
188+
onClick = {
189+
fileSelector.launch(
190+
arrayOf("*/*"),
191+
)
192+
},
211193
modifier = Modifier.weight(1f),
212194
) {
213195
Box(
@@ -359,21 +341,66 @@ fun MainContent(modifier: Modifier = Modifier) {
359341
LazyColumn(
360342
modifier = Modifier.fillMaxWidth(),
361343
) {
362-
items(items = selectedFiles.entries.flatMap { listOf(it.key) + it.value }) {
363-
if (it is String) {
364-
Text(
365-
text = it,
366-
fontWeight = FontWeight.Bold,
367-
textDecoration = TextDecoration.Underline,
368-
modifier = Modifier.padding(top = 8.dp, bottom = 0.dp),
369-
)
344+
selectedFiles.forEach { (pkg, files) ->
345+
stickyHeader {
346+
Row(
347+
modifier = Modifier.fillMaxWidth()
348+
.background(color = MaterialTheme.colorScheme.surfaceContainerHigh),
349+
horizontalArrangement = Arrangement.SpaceBetween,
350+
verticalAlignment = Alignment.CenterVertically,
351+
) {
352+
Text(
353+
text = pkg,
354+
fontWeight = FontWeight.Bold,
355+
textDecoration = TextDecoration.Underline,
356+
)
357+
358+
IconButton(
359+
onClick = {
360+
selectedFiles -= pkg
361+
},
362+
) {
363+
Icon(
364+
imageVector = Icons.Filled.Delete,
365+
contentDescription = stringResource(id = R.string.remove),
366+
)
367+
}
368+
}
370369
}
371370

372-
if (it is DocumentFile) {
373-
Text(
374-
text = it.name ?: it.uri.toString(),
375-
modifier = Modifier.padding(top = 0.dp, bottom = 4.dp),
376-
)
371+
items(items = files) {
372+
Row(
373+
modifier = Modifier.fillMaxWidth()
374+
.background(color = MaterialTheme.colorScheme.surfaceContainerHigh),
375+
horizontalArrangement = Arrangement.SpaceBetween,
376+
verticalAlignment = Alignment.CenterVertically,
377+
) {
378+
Text(
379+
text = it.name ?: it.uri.toString(),
380+
modifier = Modifier.weight(1f),
381+
)
382+
383+
if (files.size > 1) {
384+
IconButton(
385+
onClick = {
386+
selectedFiles = selectedFiles.toMutableMap().apply {
387+
val newList = this[pkg]?.minus(it)
388+
389+
if (newList.isNullOrEmpty()) {
390+
remove(pkg)
391+
} else {
392+
this[pkg] = newList
393+
}
394+
}
395+
},
396+
) {
397+
Icon(
398+
imageVector = Icons.Filled.Delete,
399+
contentDescription = stringResource(id = R.string.remove),
400+
)
401+
}
402+
}
403+
}
377404
}
378405
}
379406
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
@file:Suppress("DEPRECATION")
2+
3+
package dev.zwander.installwithoptions.util
4+
5+
import android.content.Context
6+
import android.content.pm.PackageParser
7+
import android.net.Uri
8+
import androidx.documentfile.provider.DocumentFile
9+
import dev.zwander.installwithoptions.data.DataModel
10+
import net.lingala.zip4j.ZipFile
11+
import java.io.File
12+
13+
fun Context.handleIncomingUris(uris: List<Uri>) {
14+
val currentSelection = DataModel.selectedFiles.value.toMutableMap()
15+
16+
fun addApkFile(file: DocumentFile) {
17+
val fd = contentResolver.openAssetFileDescriptor(file.uri, "r") ?: return
18+
val apkFile = PackageParser.parseApkLite(fd.fileDescriptor, file.name, 0)
19+
val packageList = currentSelection[apkFile.packageName] ?: listOf()
20+
21+
currentSelection[apkFile.packageName] = (packageList + file).distinctBy { "${apkFile.packageName}:${it.name}" }
22+
23+
fd.close()
24+
}
25+
26+
uris.forEach { uri ->
27+
val file = DocumentFile.fromSingleUri(this, uri) ?: return@forEach
28+
29+
if (file.isApk) {
30+
addApkFile(file)
31+
} else if (file.isSplitBundle) {
32+
copyZipToCacheAndExtract(file).forEach { innerFile ->
33+
addApkFile(innerFile)
34+
}
35+
}
36+
}
37+
38+
DataModel.selectedFiles.value = currentSelection
39+
}
40+
41+
private fun Context.copyZipToCacheAndExtract(zip: DocumentFile): List<DocumentFile> {
42+
val destFile = File(cacheDir, zip.name ?: zip.uri.toString())
43+
val destDir = File(cacheDir, "${destFile.name}_extracted").apply {
44+
deleteRecursively()
45+
mkdirs()
46+
}
47+
48+
contentResolver.openInputStream(zip.uri).use { input ->
49+
destFile.outputStream().use { output ->
50+
input.copyTo(output)
51+
}
52+
}
53+
54+
val zipFile = ZipFile(destFile)
55+
zipFile.extractAll(destDir.absolutePath)
56+
57+
return destDir.listFiles()?.mapNotNull { file ->
58+
val documentFile = DocumentFile.fromFile(file)
59+
60+
if (documentFile.isApk) documentFile else null
61+
} ?: listOf()
62+
}
63+
64+
private val DocumentFile.isApk: Boolean
65+
get() = type == "application/vnd.android.package-archive" || name?.endsWith(".apk") == true
66+
67+
private val DocumentFile.isSplitBundle: Boolean
68+
get() = type == "application/zip" || name?.endsWith(".xapk") == true

app/src/main/res/values/strings.xml

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<string name="mastodon">Mastodon</string>
1919
<string name="supporters">Supporters</string>
2020
<string name="settings">Settings</string>
21+
<string name="remove">Remove</string>
2122

2223
<string name="permission_intent_was_null">Permission Intent was null!</string>
2324

gradle/libs.versions.toml

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ patreonsupportersretrieval = "d2e9143db2"
1818
preference = "1.2.1"
1919
relinker = "1.4.5"
2020
shizuku = "13.1.5"
21+
zip4j = "2.11.5"
2122

2223
[libraries]
2324
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -42,6 +43,7 @@ patreonSupportersRetrieval = { module = "com.github.zacharee:PatreonSupportersRe
4243
relinker = { module = "com.getkeepsafe.relinker:relinker", version.ref = "relinker" }
4344
shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" }
4445
shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" }
46+
zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" }
4547

4648
[plugins]
4749
android-application = { id = "com.android.application", version.ref = "agp" }

0 commit comments

Comments
 (0)