mirror of
https://github.com/revanced/revanced-manager
synced 2024-05-14 13:56:57 +02:00
feat: app downloader (#43)
This commit is contained in:
parent
c36deea045
commit
94a4dbaba1
@ -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
|
||||
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
@ -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) }
|
||||
)
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
}
|
@ -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>)
|
||||
}
|
@ -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,
|
||||
)
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
@ -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")
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
|
||||
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
)
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user