Skip to content

Commit 48893af

Browse files
committed
add remote source
1 parent c75c46f commit 48893af

14 files changed

Lines changed: 1260 additions & 112 deletions

File tree

.idea/misc.xml

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src/main/AndroidManifest.xml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
88
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
99

10+
<!-- Find automatic: scan external storage for packages -->
1011
<uses-permission
11-
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
12-
tools:ignore="ScopedStorage"
13-
tools:node="remove" />
12+
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
13+
tools:ignore="ScopedStorage" />
1414
<uses-permission
1515
android:name="android.permission.READ_EXTERNAL_STORAGE"
16+
android:maxSdkVersion="29" />
17+
18+
<uses-permission
19+
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
1620
tools:ignore="ScopedStorage"
1721
tools:node="remove" />
1822
<uses-permission
@@ -120,6 +124,16 @@
120124
android:exported="true"
121125
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
122126

127+
<provider
128+
android:name="androidx.core.content.FileProvider"
129+
android:authorities="${applicationId}.fileprovider"
130+
android:exported="false"
131+
android:grantUriPermissions="true">
132+
<meta-data
133+
android:name="android.support.FILE_PROVIDER_PATHS"
134+
android:resource="@xml/file_paths" />
135+
</provider>
136+
123137
</application>
124138

125139
</manifest>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package app.pwhs.universalinstaller.data.remote
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.plugins.timeout
5+
import io.ktor.client.request.prepareGet
6+
import io.ktor.client.statement.bodyAsChannel
7+
import io.ktor.http.HttpHeaders
8+
import io.ktor.http.contentLength
9+
import io.ktor.http.isSuccess
10+
import io.ktor.utils.io.ByteReadChannel
11+
import io.ktor.utils.io.core.isEmpty
12+
import io.ktor.utils.io.core.readBytes
13+
import io.ktor.utils.io.readRemaining
14+
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.withContext
16+
import java.io.File
17+
import java.io.IOException
18+
19+
/**
20+
* Streams a package (APK / APKS / XAPK / APKM / ZIP) from a remote URL into a local file.
21+
* Progress is reported as (bytesRead, totalBytes) — totalBytes is -1 when the server
22+
* omits Content-Length. Callers should cancel the coroutine to abort.
23+
*/
24+
class PackageDownloadService(private val client: HttpClient) {
25+
26+
suspend fun download(
27+
url: String,
28+
destination: File,
29+
onProgress: (bytesRead: Long, totalBytes: Long) -> Unit = { _, _ -> },
30+
): Result<DownloadedPackage> = withContext(Dispatchers.IO) {
31+
runCatching {
32+
destination.parentFile?.mkdirs()
33+
client.prepareGet(url) {
34+
timeout { requestTimeoutMillis = DOWNLOAD_TIMEOUT_MS }
35+
}.execute { response ->
36+
if (!response.status.isSuccess()) {
37+
throw IOException("HTTP ${response.status.value} ${response.status.description}")
38+
}
39+
val total = response.contentLength() ?: -1L
40+
val fileName = response.headers[HttpHeaders.ContentDisposition]
41+
?.let { parseFileNameFromContentDisposition(it) }
42+
?: url.substringAfterLast('/').substringBefore('?').ifBlank { destination.name }
43+
44+
val channel: ByteReadChannel = response.bodyAsChannel()
45+
var read = 0L
46+
destination.outputStream().use { out ->
47+
while (!channel.isClosedForRead) {
48+
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
49+
while (!packet.isEmpty) {
50+
val bytes = packet.readBytes()
51+
out.write(bytes)
52+
read += bytes.size
53+
onProgress(read, total)
54+
}
55+
}
56+
}
57+
DownloadedPackage(file = destination, fileName = fileName, totalBytes = read)
58+
}
59+
}.onFailure { destination.delete() }
60+
}
61+
62+
private fun parseFileNameFromContentDisposition(header: String): String? {
63+
val starRegex = Regex("""filename\*\s*=\s*(?:[^']*'[^']*')?([^;\n]+)""", RegexOption.IGNORE_CASE)
64+
starRegex.find(header)?.groupValues?.get(1)?.let { raw ->
65+
val trimmed = raw.trim().trim('"')
66+
runCatching { return java.net.URLDecoder.decode(trimmed, Charsets.UTF_8.name()) }
67+
}
68+
val regex = Regex("""filename\s*=\s*"?([^";\n]+)"?""", RegexOption.IGNORE_CASE)
69+
return regex.find(header)?.groupValues?.get(1)?.trim()
70+
}
71+
72+
data class DownloadedPackage(
73+
val file: File,
74+
val fileName: String,
75+
val totalBytes: Long,
76+
)
77+
78+
companion object {
79+
private const val DEFAULT_BUFFER_SIZE = 64 * 1024
80+
private const val DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000L
81+
}
82+
}

app/src/main/java/app/pwhs/universalinstaller/di/module.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
44
import androidx.room.Room
55
import app.pwhs.universalinstaller.BuildConfig
66
import app.pwhs.universalinstaller.data.local.AppDatabase
7+
import app.pwhs.universalinstaller.data.remote.PackageDownloadService
78
import app.pwhs.universalinstaller.data.remote.VirusTotalNotifier
89
import app.pwhs.universalinstaller.data.remote.VirusTotalService
910
import app.pwhs.universalinstaller.data.repository.SessionDataRepositoryImpl
@@ -68,6 +69,7 @@ val appModule = module {
6869
}
6970
single { VirusTotalService(get()) }
7071
single { VirusTotalNotifier(get()) }
72+
single { PackageDownloadService(get()) }
7173

7274
viewModelOf(::InstallViewModel)
7375
viewModelOf(::UninstallViewModel)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package app.pwhs.universalinstaller.presentation.install
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.content.pm.PackageManager
6+
import android.os.Build
7+
import android.os.Environment
8+
import android.provider.Settings
9+
import androidx.core.content.ContextCompat
10+
import androidx.core.net.toUri
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.ensureActive
13+
import kotlinx.coroutines.withContext
14+
import java.io.File
15+
import kotlin.coroutines.coroutineContext
16+
17+
data class FoundPackageFile(
18+
val path: String,
19+
val name: String,
20+
val sizeBytes: Long,
21+
val modifiedMillis: Long,
22+
val extension: String,
23+
)
24+
25+
object ApkScanner {
26+
27+
private val SUPPORTED_EXTENSIONS = setOf("apk", "apks", "xapk", "apkm")
28+
29+
fun hasAllFilesAccess(context: Context): Boolean {
30+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
31+
Environment.isExternalStorageManager()
32+
} else {
33+
ContextCompat.checkSelfPermission(
34+
context,
35+
android.Manifest.permission.READ_EXTERNAL_STORAGE,
36+
) == PackageManager.PERMISSION_GRANTED
37+
}
38+
}
39+
40+
fun buildGrantIntent(context: Context): Intent {
41+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
42+
Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
43+
data = "package:${context.packageName}".toUri()
44+
}
45+
} else {
46+
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
47+
data = "package:${context.packageName}".toUri()
48+
}
49+
}
50+
}
51+
52+
/**
53+
* Walk external storage looking for installable package files. Returns entries sorted
54+
* newest-first. Respects coroutine cancellation so the caller can bail on a long scan.
55+
*/
56+
suspend fun scan(): List<FoundPackageFile> = withContext(Dispatchers.IO) {
57+
val root = Environment.getExternalStorageDirectory() ?: return@withContext emptyList()
58+
val results = mutableListOf<FoundPackageFile>()
59+
scanRecursive(root, results, depth = 0, maxDepth = 10)
60+
results.sortedByDescending { it.modifiedMillis }
61+
}
62+
63+
private suspend fun scanRecursive(
64+
dir: File,
65+
out: MutableList<FoundPackageFile>,
66+
depth: Int,
67+
maxDepth: Int,
68+
) {
69+
coroutineContext.ensureActive()
70+
if (depth > maxDepth) return
71+
if (!dir.exists() || !dir.canRead()) return
72+
val children = runCatching { dir.listFiles() }.getOrNull() ?: return
73+
for (child in children) {
74+
coroutineContext.ensureActive()
75+
if (child.isDirectory) {
76+
val name = child.name
77+
// Skip dotfiles, app-scoped dirs (restricted even with MANAGE access), and thumbnails.
78+
if (name.startsWith(".")) continue
79+
if (depth == 0 && name == "Android") continue
80+
scanRecursive(child, out, depth + 1, maxDepth)
81+
} else {
82+
val ext = child.extension.lowercase()
83+
if (ext in SUPPORTED_EXTENSIONS) {
84+
out.add(
85+
FoundPackageFile(
86+
path = child.absolutePath,
87+
name = child.name,
88+
sizeBytes = child.length(),
89+
modifiedMillis = child.lastModified(),
90+
extension = ext,
91+
)
92+
)
93+
}
94+
}
95+
}
96+
}
97+
98+
}

app/src/main/java/app/pwhs/universalinstaller/presentation/install/FilePickerCard.kt

Lines changed: 0 additions & 95 deletions
This file was deleted.

0 commit comments

Comments
 (0)