feat: app downloader (#43)

This commit is contained in:
Robert 2023-07-14 08:54:42 +00:00 committed by GitHub
parent b21a28187e
commit 3408e1b848
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1004 additions and 47 deletions

View File

@ -31,13 +31,14 @@ android {
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
packaging {
resources {
excludes += "/prebuilt/**"
excludes += "META-INF/DEPENDENCIES"
}
}
@ -46,7 +47,7 @@ android {
}
kotlinOptions {
jvmTarget = "17"
jvmTarget = "11"
}
buildFeatures.compose = true
@ -55,7 +56,7 @@ android {
}
kotlin {
jvmToolchain(17)
jvmToolchain(11)
}
dependencies {
@ -86,6 +87,11 @@ dependencies {
//implementation("com.google.accompanist:accompanist-flowlayout:$accompanistVersion")
//implementation("com.google.accompanist:accompanist-permissions:$accompanistVersion")
// HTML Scraper
implementation("it.skrape:skrapeit:1.1.5") {
exclude(group = "xml-apis", module = "xml-apis")
}
// Coil (async image loading, network image)
implementation("io.coil-kt:coil-compose:2.4.0")
implementation("me.zhanghai.android.appiconloader:appiconloader-coil:1.5.0")
@ -106,7 +112,7 @@ dependencies {
implementation("app.revanced:revanced-patcher:11.0.4")
// Signing
implementation("com.android.tools.build:apksig:8.2.0-alpha10")
implementation("com.android.tools.build:apksig:8.0.2")
implementation("org.bouncycastle:bcpkix-jdk15on:1.70")
// Koin

View File

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "dadad726e82673e2a4c266bf7a7c8af1",
"identityHash": "f7e0fef1b937143a8b128e3dbab7c041",
"entities": [
{
"tableName": "sources",
@ -151,12 +151,45 @@
]
}
]
},
{
"tableName": "downloaded_app",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `file` TEXT NOT NULL, PRIMARY KEY(`package_name`, `version`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "file",
"columnName": "file",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"package_name",
"version"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dadad726e82673e2a4c266bf7a7c8af1')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f7e0fef1b937143a8b128e3dbab7c041')"
]
}
}

View File

@ -8,6 +8,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.screen.AppDownloaderScreen
import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstallerScreen
@ -22,15 +23,15 @@ import dev.olshevski.navigation.reimagined.AnimatedNavHost
import dev.olshevski.navigation.reimagined.NavBackHandler
import dev.olshevski.navigation.reimagined.navigate
import dev.olshevski.navigation.reimagined.pop
import dev.olshevski.navigation.reimagined.popAll
import dev.olshevski.navigation.reimagined.popUpTo
import dev.olshevski.navigation.reimagined.rememberNavController
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
import org.koin.android.ext.android.get
import org.koin.androidx.compose.getViewModel
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
class MainActivity : ComponentActivity() {
private val prefs: PreferencesManager = get()
@ -79,11 +80,18 @@ class MainActivity : ComponentActivity() {
is Destination.AppSelector -> AppSelectorScreen(
onAppClick = { navController.navigate(Destination.PatchesSelector(it)) },
onDownloaderClick = { navController.navigate(Destination.AppDownloader(it)) },
onBackClick = { navController.pop() }
)
is Destination.PatchesSelector -> PatchesSelectorScreen(
is Destination.AppDownloader -> AppDownloaderScreen(
onBackClick = { navController.pop() },
onApkClick = { navController.navigate(Destination.PatchesSelector(it)) },
viewModel = getViewModel { parametersOf(destination.app) }
)
is Destination.PatchesSelector -> PatchesSelectorScreen(
onBackClick = { navController.popUpTo { it is Destination.AppSelector } },
onPatchClick = { patches, options ->
navController.navigate(
Destination.Installer(
@ -97,12 +105,7 @@ class MainActivity : ComponentActivity() {
)
is Destination.Installer -> InstallerScreen(
onBackClick = {
with(navController) {
popAll()
navigate(Destination.Dashboard)
}
},
onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
vm = getViewModel { parametersOf(destination) }
)
}

View File

@ -3,18 +3,21 @@ package app.revanced.manager.data.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import app.revanced.manager.data.room.apps.AppDao
import app.revanced.manager.data.room.apps.DownloadedApp
import app.revanced.manager.data.room.selection.PatchSelection
import app.revanced.manager.data.room.selection.SelectedPatch
import app.revanced.manager.data.room.selection.SelectionDao
import app.revanced.manager.data.room.sources.SourceEntity
import app.revanced.manager.data.room.sources.SourceDao
import app.revanced.manager.data.room.sources.SourceEntity
import kotlin.random.Random
@Database(entities = [SourceEntity::class, PatchSelection::class, SelectedPatch::class], version = 1)
@Database(entities = [SourceEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun sourceDao(): SourceDao
abstract fun selectionDao(): SelectionDao
abstract fun appDao(): AppDao
companion object {
fun generateUid() = Random.Default.nextInt()

View File

@ -3,6 +3,7 @@ package app.revanced.manager.data.room
import androidx.room.TypeConverter
import app.revanced.manager.data.room.sources.SourceLocation
import io.ktor.http.*
import java.io.File
class Converters {
@TypeConverter
@ -13,4 +14,10 @@ class Converters {
@TypeConverter
fun locationToString(location: SourceLocation) = location.toString()
@TypeConverter
fun fileFromString(value: String) = File(value)
@TypeConverter
fun fileToString(file: File): String = file.absolutePath
}

View File

@ -0,0 +1,22 @@
package app.revanced.manager.data.room.apps
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface AppDao {
@Query("SELECT * FROM downloaded_app")
fun getAllApps(): Flow<List<DownloadedApp>>
@Query("SELECT * FROM downloaded_app WHERE package_name = :packageName AND version = :version")
suspend fun get(packageName: String, version: String): DownloadedApp?
@Insert
suspend fun insert(downloadedApp: DownloadedApp)
@Delete
suspend fun delete(downloadedApps: Collection<DownloadedApp>)
}

View File

@ -0,0 +1,15 @@
package app.revanced.manager.data.room.apps
import androidx.room.ColumnInfo
import androidx.room.Entity
import java.io.File
@Entity(
tableName = "downloaded_app",
primaryKeys = ["package_name", "version"]
)
data class DownloadedApp(
@ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "version") val version: String,
@ColumnInfo(name = "file") val file: File,
)

View File

@ -3,6 +3,7 @@ package app.revanced.manager.di
import android.content.Context
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
@ -36,6 +37,9 @@ val httpModule = module {
install(ContentNegotiation) {
json(json)
}
install(HttpTimeout) {
socketTimeoutMillis = 10000
}
}
fun provideJson() = Json {

View File

@ -2,8 +2,8 @@ package app.revanced.manager.di
import app.revanced.manager.data.platform.FileSystem
import app.revanced.manager.domain.repository.*
import app.revanced.manager.network.api.ManagerAPI
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.network.api.ManagerAPI
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
@ -16,4 +16,5 @@ val repositoryModule = module {
singleOf(::PatchSelectionRepository)
singleOf(::SourceRepository)
singleOf(::WorkerRepository)
singleOf(::DownloadedAppRepository)
}

View File

@ -9,10 +9,12 @@ val viewModelModule = module {
viewModelOf(::PatchesSelectorViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::AppSelectorViewModel)
viewModelOf(::AppDownloaderViewModel)
viewModelOf(::SourcesViewModel)
viewModelOf(::InstallerViewModel)
viewModelOf(::UpdateProgressViewModel)
viewModelOf(::ManagerUpdateChangelogViewModel)
viewModelOf(::ImportExportViewModel)
viewModelOf(::ContributorViewModel)
viewModelOf(::DownloadsViewModel)
}

View File

@ -15,6 +15,8 @@ class PreferencesManager(
var allowExperimental by booleanPreference("allow_experimental", false)
var preferSplits by booleanPreference("prefer_splits", false)
var keystoreCommonName by stringPreference("keystore_cn", KeystoreManager.DEFAULT)
var keystorePass by stringPreference("keystore_pass", KeystoreManager.DEFAULT)
}

View File

@ -0,0 +1,36 @@
package app.revanced.manager.domain.repository
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.apps.DownloadedApp
import kotlinx.coroutines.flow.distinctUntilChanged
import java.io.File
class DownloadedAppRepository(
db: AppDatabase
) {
private val dao = db.appDao()
fun getAll() = dao.getAllApps().distinctUntilChanged()
suspend fun get(packageName: String, version: String) = dao.get(packageName, version)
suspend fun add(
packageName: String,
version: String,
file: File
) = dao.insert(
DownloadedApp(
packageName = packageName,
version = version,
file = file
)
)
suspend fun delete(downloadedApps: Collection<DownloadedApp>) {
downloadedApps.forEach {
it.file.deleteRecursively()
}
dao.delete(downloadedApps)
}
}

View File

@ -0,0 +1,278 @@
package app.revanced.manager.network.downloader
import android.os.Build.SUPPORTED_ABIS
import app.revanced.manager.network.service.HttpService
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.parameter
import io.ktor.client.request.url
import it.skrape.selects.html5.a
import it.skrape.selects.html5.div
import it.skrape.selects.html5.form
import it.skrape.selects.html5.h5
import it.skrape.selects.html5.input
import it.skrape.selects.html5.p
import it.skrape.selects.html5.span
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.File
class APKMirror : AppDownloader, KoinComponent {
private val httpClient: HttpService = get()
enum class APKType {
APK,
BUNDLE
}
data class Variant(
val apkType: APKType,
val arch: String,
val link: String
)
private val _downloadProgress: MutableStateFlow<Pair<Float, Float>?> = MutableStateFlow(null)
override val downloadProgress = _downloadProgress.asStateFlow()
private val versionMap = HashMap<String, String>()
private suspend fun getAppLink(packageName: String): String {
val searchResults = httpClient.getHtml { url("$apkMirror/?post_type=app_release&searchtype=app&s=$packageName") }
.div {
withId = "content"
findFirst {
div {
withClass = "listWidget"
findAll {
find {
it.children.first().text.contains(packageName)
}!!.children.mapNotNull {
if (it.classNames.isEmpty()) {
it.h5 {
withClass = "appRowTitle"
findFirst {
a {
findFirst {
attribute("href")
}
}
}
}
} else null
}
}
}
}
}
return searchResults.find { url ->
httpClient.getHtml { url(apkMirror + url) }
.div {
withId = "primary"
findFirst {
div {
withClass = "tab-buttons"
findFirst {
div {
withClass = "tab-button-positioning"
findFirst {
children.any {
it.attribute("href") == "https://play.google.com/store/apps/details?id=$packageName"
}
}
}
}
}
}
}
} ?: throw Exception("App isn't available for download")
}
override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow {
// Vanced music uses the same package name so we have to hardcode...
val appCategory = if (packageName == "com.google.android.apps.youtube.music")
"youtube-music"
else
getAppLink(packageName).split("/")[3]
var page = 1
while (
if (versionFilter.isNotEmpty())
versionMap.filterKeys { it in versionFilter }.size < versionFilter.size && page <= 7
else
page <= 1
) {
httpClient.getHtml {
url("$apkMirror/uploads/page/$page/")
parameter("appcategory", appCategory)
}.div {
withClass = "widget_appmanager_recentpostswidget"
findFirst {
div {
withClass = "listWidget"
findFirst {
children.mapNotNull { element ->
if (element.className.isEmpty()) {
val version = element.div {
withClass = "infoSlide"
findFirst {
p {
findFirst {
span {
withClass = "infoSlide-value"
findFirst {
text
}
}
}
}
}
}
val link = element.findFirst {
a {
withClass = "downloadLink"
findFirst {
attribute("href")
}
}
}
versionMap[version] = link
version
} else null
}
}
}
}
}.onEach { version -> emit(version) }
page++
}
}
override suspend fun downloadApp(
version: String,
saveDirectory: File,
preferSplit: Boolean
): File {
val variants = httpClient.getHtml { url(apkMirror + versionMap[version]) }
.div {
withClass = "variants-table"
findFirst { // list of variants
children.drop(1).map {
Variant(
apkType = it.div {
findFirst {
span {
findFirst {
enumValueOf(text)
}
}
}
},
arch = it.div {
findSecond {
text
}
},
link = it.div {
findFirst {
a {
findFirst {
attribute("href")
}
}
}
}
)
}
}
}
val orderedAPKTypes = mutableListOf(APKType.APK, APKType.BUNDLE)
.also { if (preferSplit) it.reverse() }
val variant = orderedAPKTypes.firstNotNullOfOrNull { apkType ->
supportedArches.firstNotNullOfOrNull { arch ->
variants.find { it.arch == arch && it.apkType == apkType }
}
} ?: throw Exception("No compatible variant found")
if (variant.apkType == APKType.BUNDLE) TODO("\nSplit apks are not supported yet")
val downloadPage = httpClient.getHtml { url(apkMirror + variant.link) }
.a {
withClass = "downloadButton"
findFirst {
attribute("href")
}
}
val downloadLink = httpClient.getHtml { url(apkMirror + downloadPage) }
.form {
withId = "filedownload"
findFirst {
val apkLink = attribute("action")
val id = input {
withAttribute = "name" to "id"
findFirst {
attribute("value")
}
}
val key = input {
withAttribute = "name" to "key"
findFirst {
attribute("value")
}
}
"$apkLink?id=$id&key=$key"
}
}
val saveLocation = if (variant.apkType == APKType.BUNDLE)
saveDirectory.resolve(version).also { it.mkdirs() }
else
saveDirectory.resolve("$version.apk")
try {
val downloadLocation = if (variant.apkType == APKType.BUNDLE)
saveLocation.resolve("temp.zip")
else
saveLocation
httpClient.download(downloadLocation) {
url(apkMirror + downloadLink)
onDownload { bytesSentTotal, contentLength ->
_downloadProgress.emit(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
}
}
if (variant.apkType == APKType.BUNDLE) {
// TODO: Extract temp.zip
downloadLocation.delete()
}
} catch (e: Exception) {
saveLocation.deleteRecursively()
throw e
} finally {
_downloadProgress.emit(null)
}
return saveLocation
}
companion object {
const val apkMirror = "https://www.apkmirror.com"
val supportedArches = listOf("universal", "noarch") + SUPPORTED_ABIS
}
}

View File

@ -0,0 +1,31 @@
package app.revanced.manager.network.downloader
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
interface AppDownloader {
val downloadProgress: StateFlow<Pair<Float, Float>?>
/**
* Returns all downloadable apps.
*
* @param packageName The package name of the app.
* @param versionFilter A set of versions to filter.
*/
fun getAvailableVersions(packageName: String, versionFilter: Set<String>): Flow<String>
/**
* Downloads the specific app version.
*
* @param version The version to download.
* @param saveDirectory The folder where the downloaded app should be stored.
* @param preferSplit Whether it should prefer a split or a full apk.
* @return the downloaded apk or the folder containing all split apks.
*/
suspend fun downloadApp(
version: String,
saveDirectory: File,
preferSplit: Boolean = false
): File
}

View File

@ -5,11 +5,21 @@ import app.revanced.manager.network.utils.APIError
import app.revanced.manager.network.utils.APIFailure
import app.revanced.manager.network.utils.APIResponse
import app.revanced.manager.util.tag
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.prepareGet
import io.ktor.client.request.request
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.http.isSuccess
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.core.isNotEmpty
import io.ktor.utils.io.core.readBytes
import it.skrape.core.htmlDocument
import kotlinx.serialization.json.Json
import java.io.File
/**
* @author Aliucord Authors, DiamondMiner88
@ -48,4 +58,34 @@ class HttpService(
}
return response
}
suspend fun download(
saveLocation: File,
builder: HttpRequestBuilder.() -> Unit
) {
http.prepareGet(builder).execute { httpResponse ->
if (httpResponse.status.isSuccess()) {
saveLocation.outputStream().use { stream ->
val channel: ByteReadChannel = httpResponse.body()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (packet.isNotEmpty) {
val bytes = packet.readBytes()
stream.write(bytes)
}
}
}
} else {
throw HttpException(httpResponse.status)
}
}
}
suspend fun getHtml(builder: HttpRequestBuilder.() -> Unit) = htmlDocument(
html = http.get(builder).bodyAsText()
)
class HttpException(status: HttpStatusCode) : Exception("Failed to fetch: http status: $status")
}

View File

@ -9,18 +9,17 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@Composable
fun LoadingIndicator(progress: Float? = null, text: Int? = null) {
fun LoadingIndicator(progress: Float? = null, text: String? = null) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (text != null)
Text(stringResource(text))
Text(text)
if (progress == null) {
CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp))
} else {

View File

@ -18,6 +18,9 @@ sealed interface Destination : Parcelable {
@Parcelize
object Settings : Destination
@Parcelize
data class AppDownloader(val app: AppInfo) : Destination
@Parcelize
data class PatchesSelector(val input: AppInfo) : Destination

View File

@ -0,0 +1,151 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.viewmodel.AppDownloaderViewModel
import app.revanced.manager.util.AppInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppDownloaderScreen(
onBackClick: () -> Unit,
onApkClick: (AppInfo) -> Unit,
viewModel: AppDownloaderViewModel
) {
SideEffect {
viewModel.onComplete = onApkClick
}
val downloadProgress by viewModel.appDownloader.downloadProgress.collectAsStateWithLifecycle()
val compatibleVersions by viewModel.compatibleVersions.collectAsStateWithLifecycle(emptyMap())
val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList())
val list by remember {
derivedStateOf {
(downloadedVersions + viewModel.availableVersions)
.distinct()
.sortedWith(
compareByDescending<String> {
downloadedVersions.contains(it)
}.thenByDescending { compatibleVersions[it] }
.thenByDescending { it }
)
}
}
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.select_version),
onBackClick = onBackClick,
actions = {
IconButton(onClick = { }) {
Icon(Icons.Outlined.HelpOutline, stringResource(R.string.help))
}
IconButton(onClick = { }) {
Icon(Icons.Outlined.Search, stringResource(R.string.search))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
when {
!viewModel.isDownloading && list.isNotEmpty() -> {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
list.forEach { version ->
ListItem(
modifier = Modifier.clickable {
viewModel.downloadApp(version)
},
headlineContent = { Text(version) },
supportingContent =
if (downloadedVersions.contains(version)) {
{ Text(stringResource(R.string.already_downloaded)) }
} else null,
trailingContent = compatibleVersions[version]?.let {
{
Text(
pluralStringResource(
R.plurals.patches_count,
count = it,
it
)
)
}
}
)
}
if (viewModel.errorMessage != null) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.error_occurred))
Text(
text = viewModel.errorMessage!!,
modifier = Modifier.padding(horizontal = 15.dp)
)
}
} else if (viewModel.isLoading)
LoadingIndicator()
}
}
viewModel.errorMessage != null -> {
Text(stringResource(R.string.error_occurred))
Text(
text = viewModel.errorMessage!!,
modifier = Modifier.padding(horizontal = 15.dp)
)
}
else -> {
LoadingIndicator(
progress = downloadProgress?.let { (it.first / it.second) },
text = downloadProgress?.let { stringResource(R.string.downloading_app, it.first, it.second) }
)
}
}
}
}
}

View File

@ -19,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -30,18 +31,24 @@ import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.AppInfo
import app.revanced.manager.util.toast
import org.koin.androidx.compose.getViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSelectorScreen(
onAppClick: (AppInfo) -> Unit,
onDownloaderClick: (AppInfo) -> Unit,
onBackClick: () -> Unit,
vm: AppSelectorViewModel = getViewModel()
) {
val context = LocalContext.current
val pickApkLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
it?.let { apkUri -> onAppClick(vm.loadSelectedFile(apkUri)) }
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { apkUri ->
vm.loadSelectedFile(apkUri)?.let(onAppClick) ?: context.toast(context.getString(R.string.failed_to_load_apk))
}
}
var filterText by rememberSaveable { mutableStateOf("") }
@ -57,6 +64,17 @@ fun AppSelectorScreen(
}
}
var selectedApp: AppInfo? by rememberSaveable { mutableStateOf(null) }
selectedApp?.let {
VersionDialog(
selectedApp = it,
onDismissRequest = { selectedApp = null },
onSelectVersionClick = onDownloaderClick,
onContinueClick = onAppClick
)
}
// TODO: find something better for this
if (search) {
SearchBar(
@ -121,7 +139,9 @@ fun AppSelectorScreen(
}
) { paddingValues ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues)
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
item {
ListItem(
@ -149,9 +169,7 @@ fun AppSelectorScreen(
) { app ->
ListItem(
modifier = Modifier.clickable {
app.packageInfo?.let { onAppClick(app) }
},
modifier = Modifier.clickable { selectedApp = app },
leadingContent = { AppIcon(app, null) },
headlineContent = { Text(vm.loadLabel(app.packageInfo)) },
supportingContent = { Text(app.packageName) },
@ -165,3 +183,50 @@ fun AppSelectorScreen(
}
}
}
@Composable
fun VersionDialog(
selectedApp: AppInfo,
onDismissRequest: () -> Unit,
onSelectVersionClick: (AppInfo) -> Unit,
onContinueClick: (AppInfo) -> Unit
) = if (selectedApp.packageInfo != null) AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(R.string.continue_with_version)) },
text = { Text(stringResource(R.string.version_not_supported, selectedApp.packageInfo.versionName)) },
confirmButton = {
Column(
horizontalAlignment = Alignment.End
) {
TextButton(onClick = {
onSelectVersionClick(selectedApp)
onDismissRequest()
}) {
Text(stringResource(R.string.download_another_version))
}
TextButton(onClick = {
onContinueClick(selectedApp)
onDismissRequest()
}) {
Text(stringResource(R.string.continue_anyways))
}
}
}
) else AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(R.string.download_application)) },
text = { Text(stringResource(R.string.app_not_installed)) },
confirmButton = {
TextButton(onClick = {
onDismissRequest()
}) {
Text(stringResource(R.string.cancel))
}
TextButton(onClick = {
onSelectVersionClick(selectedApp)
onDismissRequest()
}) {
Text(stringResource(R.string.download_app))
}
}
)

View File

@ -1,5 +1,6 @@
package app.revanced.manager.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -65,6 +66,8 @@ fun PatchesSelectorScreen(
onBackClick: () -> Unit,
vm: PatchesSelectorViewModel
) {
BackHandler(onBack = onBackClick)
val pagerState = rememberPagerState()
val composableScope = rememberCoroutineScope()

View File

@ -1,35 +1,82 @@
package app.revanced.manager.ui.screen.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.viewmodel.DownloadsViewModel
import org.koin.androidx.compose.getViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DownloadsSettingsScreen(
onBackClick: () -> Unit
onBackClick: () -> Unit,
viewModel: DownloadsViewModel = getViewModel()
) {
val prefs = viewModel.prefs
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(initialValue = emptyList())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.downloads),
onBackClick = onBackClick
onBackClick = onBackClick,
actions = {
if (viewModel.selection.isNotEmpty()) {
IconButton(onClick = { viewModel.delete() }) {
Icon(Icons.Default.Delete, stringResource(R.string.delete))
}
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier.fillMaxSize().padding(paddingValues).verticalScroll(rememberScrollState())
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
ListItem(
modifier = Modifier.clickable { prefs.preferSplits = !prefs.preferSplits },
headlineContent = { Text(stringResource(R.string.prefer_splits)) },
supportingContent = { Text(stringResource(R.string.prefer_splits_description)) },
trailingContent = {
Switch(checked = prefs.preferSplits, onCheckedChange = { prefs.preferSplits = it })
}
)
GroupHeader(stringResource(R.string.downloaded_apps))
downloadedApps.forEach {
ListItem(
modifier = Modifier.clickable { viewModel.toggleItem(it) },
headlineContent = { Text(it.packageName) },
supportingContent = { Text(it.version) },
tonalElevation = if (viewModel.selection.contains(it)) 8.dp else 0.dp
)
}
}
}
}

View File

@ -0,0 +1,144 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.network.downloader.APKMirror
import app.revanced.manager.network.downloader.AppDownloader
import app.revanced.manager.util.AppInfo
import app.revanced.manager.util.PM
import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class AppDownloaderViewModel(
private val selectedApp: AppInfo
) : ViewModel(), KoinComponent {
private val app: Application = get()
private val downloadedAppRepository: DownloadedAppRepository = get()
private val sourceRepository: SourceRepository = get()
private val pm: PM = get()
private val prefs: PreferencesManager = get()
val appDownloader: AppDownloader = APKMirror()
var isDownloading: Boolean by mutableStateOf(false)
private set
var isLoading by mutableStateOf(true)
private set
var errorMessage: String? by mutableStateOf(null)
private set
val availableVersions = mutableStateSetOf<String>()
val compatibleVersions = sourceRepository.bundles.map { bundles ->
var patchesWithoutVersions = 0
bundles.flatMap { (_, bundle) ->
bundle.patches.flatMap { patch ->
patch.compatiblePackages
.orEmpty()
.filter { it.name == selectedApp.packageName }
.onEach {
if (it.versions.isEmpty()) patchesWithoutVersions += 1
}
.flatMap { it.versions }
}
}.groupingBy { it }
.eachCount()
.toMutableMap()
.apply {
replaceAll { _, count ->
count + patchesWithoutVersions
}
}
}
val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps ->
downloadedApps.mapNotNull {
if (it.packageName == selectedApp.packageName)
it.version
else
null
}
}
private val job = viewModelScope.launch(Dispatchers.IO) {
try {
val compatibleVersions = compatibleVersions.first()
appDownloader.getAvailableVersions(
selectedApp.packageName,
compatibleVersions.keys
).collect {
if (it in compatibleVersions || compatibleVersions.isEmpty()) {
availableVersions.add(it)
}
}
withContext(Dispatchers.Main) {
isLoading = false
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Log.e(tag, "Failed to load apps", e)
errorMessage = e.simpleMessage()
}
}
}
lateinit var onComplete: (AppInfo) -> Unit
fun downloadApp(
version: String
) {
isDownloading = true
job.cancel()
viewModelScope.launch(Dispatchers.IO) {
try {
val savePath = app.filesDir.resolve("downloaded-apps").resolve(selectedApp.packageName).also { it.mkdirs() }
val downloadedFile =
downloadedAppRepository.get(selectedApp.packageName, version)?.file
?: appDownloader.downloadApp(
version,
savePath,
preferSplit = prefs.preferSplits
).also {
downloadedAppRepository.add(
selectedApp.packageName,
version,
it
)
}
val apkInfo = pm.getApkInfo(downloadedFile)
?: throw Exception("Failed to load apk info")
withContext(Dispatchers.Main) {
onComplete(apkInfo)
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
Log.e(tag, "Failed to download apk", e)
errorMessage = e.simpleMessage()
}
}
}
}
}

View File

@ -0,0 +1,46 @@
package app.revanced.manager.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.room.apps.DownloadedApp
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.util.mutableStateSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DownloadsViewModel(
private val downloadedAppRepository: DownloadedAppRepository,
val prefs: PreferencesManager
) : ViewModel() {
val downloadedApps = downloadedAppRepository.getAll().map { downloadedApps ->
downloadedApps.sortedWith(
compareBy<DownloadedApp> {
it.packageName
}.thenBy { it.version }
)
}
val selection = mutableStateSetOf<DownloadedApp>()
fun toggleItem(downloadedApp: DownloadedApp) {
if (selection.contains(downloadedApp))
selection.remove(downloadedApp)
else
selection.add(downloadedApp)
}
fun delete() {
viewModelScope.launch(NonCancellable) {
downloadedAppRepository.delete(selection)
withContext(Dispatchers.Main) {
selection.clear()
}
}
}
}

View File

@ -15,13 +15,10 @@ import androidx.compose.runtime.Immutable
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import java.io.File
@ -43,12 +40,10 @@ class PM(
private val app: Application,
private val sourceRepository: SourceRepository
) {
private val coroutineScope = CoroutineScope(Dispatchers.Default)
private val installedApps = MutableStateFlow(emptyList<AppInfo>())
private val compatibleApps = MutableStateFlow(emptyList<AppInfo>())
val appList: StateFlow<List<AppInfo>> = compatibleApps.combine(installedApps) { compatibleApps, installedApps ->
val appList: Flow<List<AppInfo>> = compatibleApps.combine(installedApps) { compatibleApps, installedApps ->
if (compatibleApps.isNotEmpty()) {
(compatibleApps + installedApps)
.distinctBy { it.packageName }
@ -60,7 +55,7 @@ class PM(
} else {
emptyList()
}
}.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
}
suspend fun getCompatibleApps() {
sourceRepository.bundles.collect { bundles ->
@ -125,7 +120,7 @@ class PM(
app.startActivity(it)
}
fun getApkInfo(apk: File) = app.packageManager.getPackageArchiveInfo(apk.path, 0)!!.let {
fun getApkInfo(apk: File) = app.packageManager.getPackageArchiveInfo(apk.path, 0)?.let {
AppInfo(
it.packageName,
0,

View File

@ -67,13 +67,15 @@ inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, bl
context.toast(
context.getString(
toastMsg,
error.message ?: error.cause?.message ?: error::class.simpleName
error.simpleMessage()
)
)
Log.e(tag, logMsg, error)
}
}
fun Throwable.simpleMessage() = this.message ?: this.cause?.message ?: this::class.simpleName
inline fun LifecycleOwner.launchAndRepeatWithViewLifecycle(
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
crossinline block: suspend CoroutineScope.() -> Unit

View File

@ -10,6 +10,7 @@
<string name="settings">Settings</string>
<string name="select_app">Select an app</string>
<string name="select_patches">Select patches</string>
<string name="select_version">Select version</string>
<string name="general">General</string>
<string name="general_description">General settings</string>
@ -56,7 +57,11 @@
<string name="backup_patches_selection_fail">Failed to backup patches selection: %s</string>
<string name="clear_patches_selection">Clear patches selection</string>
<string name="clear_patches_selection_description">Clear all patches selection</string>
<string name="prefer_splits">Prefer split apks</string>
<string name="prefer_splits_description">Prefer split apks instead of full apks</string>
<string name="prefer_universal">Prefer universal apks</string>
<string name="prefer_universal_description">Prefer universal instead of arch-specific apks</string>
<string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string>
<string name="downloading_patches">Downloading patch bundle…</string>
@ -71,10 +76,12 @@
<string name="help">Help</string>
<string name="back">Back</string>
<string name="add">Add</string>
<string name="delete">Delete</string>
<string name="system">System</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="appearance">Appearance</string>
<string name="downloaded_apps">Downloaded apps</string>
<string name="device">Device</string>
<string name="device_android_version">Android version</string>
<string name="device_model">Model</string>
@ -87,6 +94,9 @@
<string name="tab_apps">Apps</string>
<string name="tab_sources">Sources</string>
<string name="reload_sources">Reload all sources</string>
<string name="continue_anyways">Continue anyways</string>
<string name="download_another_version">Download another version</string>
<string name="download_app">Download app</string>
<string name="source_download_fail">Failed to download patch bundle: %s</string>
<string name="source_replace_fail">Failed to load updated patch bundle: %s</string>
<string name="source_replace_integrations_fail">Failed to update integrations: %s</string>
@ -97,7 +107,16 @@
<string name="supported">Supported</string>
<string name="universal">Universal</string>
<string name="unsupported">Unsupported</string>
<string name="app_not_supported">Some of the patches do not support this app version (%1$s). The patches only support the following versions: %2$s.</string>
<string name="app_not_supported">Some of the patches do not support this app version (%1$s). The patches only support the following version(s): %2$s.</string>
<string name="continue_with_version">Continue with this version?</string>
<string name="version_not_supported">Not all patches support this version (%s). Do you want to continue anyway?</string>
<string name="download_application">Download application?</string>
<string name="app_not_installed">The app you selected isn\'t installed. Do you want to download it?</string>
<string name="failed_to_load_apk">Failed to load apk</string>
<string name="error_occurred">An error occurred</string>
<string name="already_downloaded">Already downloaded</string>
<string name="downloading_app">Downloading app… (%1$s MB/%2$s MB)</string>
<string name="select_file">Select file</string>