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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/prebuilt/**"
|
excludes += "/prebuilt/**"
|
||||||
|
excludes += "META-INF/DEPENDENCIES"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "17"
|
jvmTarget = "11"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures.compose = true
|
buildFeatures.compose = true
|
||||||
@ -55,7 +56,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(17)
|
jvmToolchain(11)
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@ -86,6 +87,11 @@ dependencies {
|
|||||||
//implementation("com.google.accompanist:accompanist-flowlayout:$accompanistVersion")
|
//implementation("com.google.accompanist:accompanist-flowlayout:$accompanistVersion")
|
||||||
//implementation("com.google.accompanist:accompanist-permissions:$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)
|
// Coil (async image loading, network image)
|
||||||
implementation("io.coil-kt:coil-compose:2.4.0")
|
implementation("io.coil-kt:coil-compose:2.4.0")
|
||||||
implementation("me.zhanghai.android.appiconloader:appiconloader-coil:1.5.0")
|
implementation("me.zhanghai.android.appiconloader:appiconloader-coil:1.5.0")
|
||||||
@ -106,7 +112,7 @@ dependencies {
|
|||||||
implementation("app.revanced:revanced-patcher:11.0.4")
|
implementation("app.revanced:revanced-patcher:11.0.4")
|
||||||
|
|
||||||
// Signing
|
// 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")
|
implementation("org.bouncycastle:bcpkix-jdk15on:1.70")
|
||||||
|
|
||||||
// Koin
|
// Koin
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"identityHash": "dadad726e82673e2a4c266bf7a7c8af1",
|
"identityHash": "f7e0fef1b937143a8b128e3dbab7c041",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "sources",
|
"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": [],
|
"views": [],
|
||||||
"setupQueries": [
|
"setupQueries": [
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
"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 androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import app.revanced.manager.domain.manager.PreferencesManager
|
import app.revanced.manager.domain.manager.PreferencesManager
|
||||||
import app.revanced.manager.ui.destination.Destination
|
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.AppSelectorScreen
|
||||||
import app.revanced.manager.ui.screen.DashboardScreen
|
import app.revanced.manager.ui.screen.DashboardScreen
|
||||||
import app.revanced.manager.ui.screen.InstallerScreen
|
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.NavBackHandler
|
||||||
import dev.olshevski.navigation.reimagined.navigate
|
import dev.olshevski.navigation.reimagined.navigate
|
||||||
import dev.olshevski.navigation.reimagined.pop
|
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 dev.olshevski.navigation.reimagined.rememberNavController
|
||||||
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
|
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
|
||||||
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
|
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.androidx.compose.getViewModel
|
import org.koin.androidx.compose.getViewModel
|
||||||
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
|
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val prefs: PreferencesManager = get()
|
private val prefs: PreferencesManager = get()
|
||||||
@ -79,11 +80,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
is Destination.AppSelector -> AppSelectorScreen(
|
is Destination.AppSelector -> AppSelectorScreen(
|
||||||
onAppClick = { navController.navigate(Destination.PatchesSelector(it)) },
|
onAppClick = { navController.navigate(Destination.PatchesSelector(it)) },
|
||||||
|
onDownloaderClick = { navController.navigate(Destination.AppDownloader(it)) },
|
||||||
onBackClick = { navController.pop() }
|
onBackClick = { navController.pop() }
|
||||||
)
|
)
|
||||||
|
|
||||||
is Destination.PatchesSelector -> PatchesSelectorScreen(
|
is Destination.AppDownloader -> AppDownloaderScreen(
|
||||||
onBackClick = { navController.pop() },
|
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 ->
|
onPatchClick = { patches, options ->
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
Destination.Installer(
|
Destination.Installer(
|
||||||
@ -97,12 +105,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
is Destination.Installer -> InstallerScreen(
|
is Destination.Installer -> InstallerScreen(
|
||||||
onBackClick = {
|
onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
|
||||||
with(navController) {
|
|
||||||
popAll()
|
|
||||||
navigate(Destination.Dashboard)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
vm = getViewModel { parametersOf(destination) }
|
vm = getViewModel { parametersOf(destination) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -3,18 +3,21 @@ package app.revanced.manager.data.room
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
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.PatchSelection
|
||||||
import app.revanced.manager.data.room.selection.SelectedPatch
|
import app.revanced.manager.data.room.selection.SelectedPatch
|
||||||
import app.revanced.manager.data.room.selection.SelectionDao
|
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.SourceDao
|
||||||
|
import app.revanced.manager.data.room.sources.SourceEntity
|
||||||
import kotlin.random.Random
|
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)
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun sourceDao(): SourceDao
|
abstract fun sourceDao(): SourceDao
|
||||||
abstract fun selectionDao(): SelectionDao
|
abstract fun selectionDao(): SelectionDao
|
||||||
|
abstract fun appDao(): AppDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun generateUid() = Random.Default.nextInt()
|
fun generateUid() = Random.Default.nextInt()
|
||||||
|
@ -3,6 +3,7 @@ package app.revanced.manager.data.room
|
|||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import app.revanced.manager.data.room.sources.SourceLocation
|
import app.revanced.manager.data.room.sources.SourceLocation
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
@ -13,4 +14,10 @@ class Converters {
|
|||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun locationToString(location: SourceLocation) = location.toString()
|
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 android.content.Context
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.engine.okhttp.*
|
import io.ktor.client.engine.okhttp.*
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@ -36,6 +37,9 @@ val httpModule = module {
|
|||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(json)
|
json(json)
|
||||||
}
|
}
|
||||||
|
install(HttpTimeout) {
|
||||||
|
socketTimeoutMillis = 10000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun provideJson() = Json {
|
fun provideJson() = Json {
|
||||||
|
@ -2,8 +2,8 @@ package app.revanced.manager.di
|
|||||||
|
|
||||||
import app.revanced.manager.data.platform.FileSystem
|
import app.revanced.manager.data.platform.FileSystem
|
||||||
import app.revanced.manager.domain.repository.*
|
import app.revanced.manager.domain.repository.*
|
||||||
import app.revanced.manager.network.api.ManagerAPI
|
|
||||||
import app.revanced.manager.domain.worker.WorkerRepository
|
import app.revanced.manager.domain.worker.WorkerRepository
|
||||||
|
import app.revanced.manager.network.api.ManagerAPI
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
@ -16,4 +16,5 @@ val repositoryModule = module {
|
|||||||
singleOf(::PatchSelectionRepository)
|
singleOf(::PatchSelectionRepository)
|
||||||
singleOf(::SourceRepository)
|
singleOf(::SourceRepository)
|
||||||
singleOf(::WorkerRepository)
|
singleOf(::WorkerRepository)
|
||||||
|
singleOf(::DownloadedAppRepository)
|
||||||
}
|
}
|
@ -9,10 +9,12 @@ val viewModelModule = module {
|
|||||||
viewModelOf(::PatchesSelectorViewModel)
|
viewModelOf(::PatchesSelectorViewModel)
|
||||||
viewModelOf(::SettingsViewModel)
|
viewModelOf(::SettingsViewModel)
|
||||||
viewModelOf(::AppSelectorViewModel)
|
viewModelOf(::AppSelectorViewModel)
|
||||||
|
viewModelOf(::AppDownloaderViewModel)
|
||||||
viewModelOf(::SourcesViewModel)
|
viewModelOf(::SourcesViewModel)
|
||||||
viewModelOf(::InstallerViewModel)
|
viewModelOf(::InstallerViewModel)
|
||||||
viewModelOf(::UpdateProgressViewModel)
|
viewModelOf(::UpdateProgressViewModel)
|
||||||
viewModelOf(::ManagerUpdateChangelogViewModel)
|
viewModelOf(::ManagerUpdateChangelogViewModel)
|
||||||
viewModelOf(::ImportExportViewModel)
|
viewModelOf(::ImportExportViewModel)
|
||||||
viewModelOf(::ContributorViewModel)
|
viewModelOf(::ContributorViewModel)
|
||||||
|
viewModelOf(::DownloadsViewModel)
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,8 @@ class PreferencesManager(
|
|||||||
|
|
||||||
var allowExperimental by booleanPreference("allow_experimental", false)
|
var allowExperimental by booleanPreference("allow_experimental", false)
|
||||||
|
|
||||||
|
var preferSplits by booleanPreference("prefer_splits", false)
|
||||||
|
|
||||||
var keystoreCommonName by stringPreference("keystore_cn", KeystoreManager.DEFAULT)
|
var keystoreCommonName by stringPreference("keystore_cn", KeystoreManager.DEFAULT)
|
||||||
var keystorePass by stringPreference("keystore_pass", 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.APIFailure
|
||||||
import app.revanced.manager.network.utils.APIResponse
|
import app.revanced.manager.network.utils.APIResponse
|
||||||
import app.revanced.manager.util.tag
|
import app.revanced.manager.util.tag
|
||||||
import io.ktor.client.*
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.request.HttpRequestBuilder
|
||||||
import io.ktor.http.*
|
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 kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Aliucord Authors, DiamondMiner88
|
* @author Aliucord Authors, DiamondMiner88
|
||||||
@ -48,4 +58,34 @@ class HttpService(
|
|||||||
}
|
}
|
||||||
return response
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoadingIndicator(progress: Float? = null, text: Int? = null) {
|
fun LoadingIndicator(progress: Float? = null, text: String? = null) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
if (text != null)
|
if (text != null)
|
||||||
Text(stringResource(text))
|
Text(text)
|
||||||
if (progress == null) {
|
if (progress == null) {
|
||||||
CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp))
|
CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp))
|
||||||
} else {
|
} else {
|
||||||
|
@ -18,6 +18,9 @@ sealed interface Destination : Parcelable {
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
object Settings : Destination
|
object Settings : Destination
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class AppDownloader(val app: AppInfo) : Destination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class PatchesSelector(val input: AppInfo) : Destination
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
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.ui.viewmodel.AppSelectorViewModel
|
||||||
import app.revanced.manager.util.APK_MIMETYPE
|
import app.revanced.manager.util.APK_MIMETYPE
|
||||||
import app.revanced.manager.util.AppInfo
|
import app.revanced.manager.util.AppInfo
|
||||||
|
import app.revanced.manager.util.toast
|
||||||
import org.koin.androidx.compose.getViewModel
|
import org.koin.androidx.compose.getViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppSelectorScreen(
|
fun AppSelectorScreen(
|
||||||
onAppClick: (AppInfo) -> Unit,
|
onAppClick: (AppInfo) -> Unit,
|
||||||
|
onDownloaderClick: (AppInfo) -> Unit,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
vm: AppSelectorViewModel = getViewModel()
|
vm: AppSelectorViewModel = getViewModel()
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
val pickApkLauncher =
|
val pickApkLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
|
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
it?.let { apkUri -> onAppClick(vm.loadSelectedFile(apkUri)) }
|
uri?.let { apkUri ->
|
||||||
|
vm.loadSelectedFile(apkUri)?.let(onAppClick) ?: context.toast(context.getString(R.string.failed_to_load_apk))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var filterText by rememberSaveable { mutableStateOf("") }
|
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
|
// TODO: find something better for this
|
||||||
if (search) {
|
if (search) {
|
||||||
SearchBar(
|
SearchBar(
|
||||||
@ -121,7 +139,9 @@ fun AppSelectorScreen(
|
|||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(paddingValues)
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@ -149,9 +169,7 @@ fun AppSelectorScreen(
|
|||||||
) { app ->
|
) { app ->
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable { selectedApp = app },
|
||||||
app.packageInfo?.let { onAppClick(app) }
|
|
||||||
},
|
|
||||||
leadingContent = { AppIcon(app, null) },
|
leadingContent = { AppIcon(app, null) },
|
||||||
headlineContent = { Text(vm.loadLabel(app.packageInfo)) },
|
headlineContent = { Text(vm.loadLabel(app.packageInfo)) },
|
||||||
supportingContent = { Text(app.packageName) },
|
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
|
package app.revanced.manager.ui.screen
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@ -65,6 +66,8 @@ fun PatchesSelectorScreen(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
vm: PatchesSelectorViewModel
|
vm: PatchesSelectorViewModel
|
||||||
) {
|
) {
|
||||||
|
BackHandler(onBack = onBackClick)
|
||||||
|
|
||||||
val pagerState = rememberPagerState()
|
val pagerState = rememberPagerState()
|
||||||
val composableScope = rememberCoroutineScope()
|
val composableScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
@ -1,35 +1,82 @@
|
|||||||
package app.revanced.manager.ui.screen.settings
|
package app.revanced.manager.ui.screen.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.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.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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.R
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DownloadsSettingsScreen(
|
fun DownloadsSettingsScreen(
|
||||||
onBackClick: () -> Unit
|
onBackClick: () -> Unit,
|
||||||
|
viewModel: DownloadsViewModel = getViewModel()
|
||||||
) {
|
) {
|
||||||
|
val prefs = viewModel.prefs
|
||||||
|
|
||||||
|
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
AppTopBar(
|
AppTopBar(
|
||||||
title = stringResource(R.string.downloads),
|
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 ->
|
) { paddingValues ->
|
||||||
Column(
|
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.domain.repository.SourceRepository
|
||||||
import app.revanced.manager.service.InstallService
|
import app.revanced.manager.service.InstallService
|
||||||
import app.revanced.manager.service.UninstallService
|
import app.revanced.manager.service.UninstallService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -43,12 +40,10 @@ class PM(
|
|||||||
private val app: Application,
|
private val app: Application,
|
||||||
private val sourceRepository: SourceRepository
|
private val sourceRepository: SourceRepository
|
||||||
) {
|
) {
|
||||||
private val coroutineScope = CoroutineScope(Dispatchers.Default)
|
|
||||||
|
|
||||||
private val installedApps = MutableStateFlow(emptyList<AppInfo>())
|
private val installedApps = MutableStateFlow(emptyList<AppInfo>())
|
||||||
private val compatibleApps = 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()) {
|
if (compatibleApps.isNotEmpty()) {
|
||||||
(compatibleApps + installedApps)
|
(compatibleApps + installedApps)
|
||||||
.distinctBy { it.packageName }
|
.distinctBy { it.packageName }
|
||||||
@ -60,7 +55,7 @@ class PM(
|
|||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
|
}
|
||||||
|
|
||||||
suspend fun getCompatibleApps() {
|
suspend fun getCompatibleApps() {
|
||||||
sourceRepository.bundles.collect { bundles ->
|
sourceRepository.bundles.collect { bundles ->
|
||||||
@ -125,7 +120,7 @@ class PM(
|
|||||||
app.startActivity(it)
|
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(
|
AppInfo(
|
||||||
it.packageName,
|
it.packageName,
|
||||||
0,
|
0,
|
||||||
|
@ -67,13 +67,15 @@ inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, bl
|
|||||||
context.toast(
|
context.toast(
|
||||||
context.getString(
|
context.getString(
|
||||||
toastMsg,
|
toastMsg,
|
||||||
error.message ?: error.cause?.message ?: error::class.simpleName
|
error.simpleMessage()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Log.e(tag, logMsg, error)
|
Log.e(tag, logMsg, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Throwable.simpleMessage() = this.message ?: this.cause?.message ?: this::class.simpleName
|
||||||
|
|
||||||
inline fun LifecycleOwner.launchAndRepeatWithViewLifecycle(
|
inline fun LifecycleOwner.launchAndRepeatWithViewLifecycle(
|
||||||
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
|
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
|
||||||
crossinline block: suspend CoroutineScope.() -> Unit
|
crossinline block: suspend CoroutineScope.() -> Unit
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
<string name="select_app">Select an app</string>
|
<string name="select_app">Select an app</string>
|
||||||
<string name="select_patches">Select patches</string>
|
<string name="select_patches">Select patches</string>
|
||||||
|
<string name="select_version">Select version</string>
|
||||||
|
|
||||||
<string name="general">General</string>
|
<string name="general">General</string>
|
||||||
<string name="general_description">General settings</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="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">Clear patches selection</string>
|
||||||
<string name="clear_patches_selection_description">Clear all 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="search_apps">Search apps…</string>
|
||||||
<string name="loading_body">Loading…</string>
|
<string name="loading_body">Loading…</string>
|
||||||
<string name="downloading_patches">Downloading patch bundle…</string>
|
<string name="downloading_patches">Downloading patch bundle…</string>
|
||||||
@ -71,10 +76,12 @@
|
|||||||
<string name="help">Help</string>
|
<string name="help">Help</string>
|
||||||
<string name="back">Back</string>
|
<string name="back">Back</string>
|
||||||
<string name="add">Add</string>
|
<string name="add">Add</string>
|
||||||
|
<string name="delete">Delete</string>
|
||||||
<string name="system">System</string>
|
<string name="system">System</string>
|
||||||
<string name="light">Light</string>
|
<string name="light">Light</string>
|
||||||
<string name="dark">Dark</string>
|
<string name="dark">Dark</string>
|
||||||
<string name="appearance">Appearance</string>
|
<string name="appearance">Appearance</string>
|
||||||
|
<string name="downloaded_apps">Downloaded apps</string>
|
||||||
<string name="device">Device</string>
|
<string name="device">Device</string>
|
||||||
<string name="device_android_version">Android version</string>
|
<string name="device_android_version">Android version</string>
|
||||||
<string name="device_model">Model</string>
|
<string name="device_model">Model</string>
|
||||||
@ -87,6 +94,9 @@
|
|||||||
<string name="tab_apps">Apps</string>
|
<string name="tab_apps">Apps</string>
|
||||||
<string name="tab_sources">Sources</string>
|
<string name="tab_sources">Sources</string>
|
||||||
<string name="reload_sources">Reload all 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_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_fail">Failed to load updated patch bundle: %s</string>
|
||||||
<string name="source_replace_integrations_fail">Failed to update integrations: %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="supported">Supported</string>
|
||||||
<string name="universal">Universal</string>
|
<string name="universal">Universal</string>
|
||||||
<string name="unsupported">Unsupported</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>
|
<string name="select_file">Select file</string>
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user