diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7351a84c..bb0f1463 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json index f47f93a8..48aa6323 100644 --- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 8b27d507..3fb132a9 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -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) } ) } diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt index 4515bbe1..432f40e9 100644 --- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt +++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt @@ -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() diff --git a/app/src/main/java/app/revanced/manager/data/room/Converters.kt b/app/src/main/java/app/revanced/manager/data/room/Converters.kt index bde69172..ceba65df 100644 --- a/app/src/main/java/app/revanced/manager/data/room/Converters.kt +++ b/app/src/main/java/app/revanced/manager/data/room/Converters.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/AppDao.kt b/app/src/main/java/app/revanced/manager/data/room/apps/AppDao.kt new file mode 100644 index 00000000..868172ee --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/apps/AppDao.kt @@ -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> + + @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) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/DownloadedApp.kt b/app/src/main/java/app/revanced/manager/data/room/apps/DownloadedApp.kt new file mode 100644 index 00000000..d0f50bb4 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/apps/DownloadedApp.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/HttpModule.kt b/app/src/main/java/app/revanced/manager/di/HttpModule.kt index 05a5547d..38621b0c 100644 --- a/app/src/main/java/app/revanced/manager/di/HttpModule.kt +++ b/app/src/main/java/app/revanced/manager/di/HttpModule.kt @@ -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 { diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt index d07ee1e5..5646751d 100644 --- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index d5348b67..870c1571 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -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) } diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index 1c5d11aa..fe1393ad 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -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) } diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt new file mode 100644 index 00000000..1cb37cbe --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt @@ -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) { + downloadedApps.forEach { + it.file.deleteRecursively() + } + + dao.delete(downloadedApps) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt b/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt new file mode 100644 index 00000000..55682fb9 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt @@ -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?> = MutableStateFlow(null) + override val downloadProgress = _downloadProgress.asStateFlow() + + private val versionMap = HashMap() + + 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) = 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 + } + +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt b/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt new file mode 100644 index 00000000..fd657d16 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt @@ -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?> + + /** + * 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): Flow + + /** + * 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 +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt index c28913b5..3781c3b4 100644 --- a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt +++ b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt @@ -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") } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt b/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt index 2413d150..e5772789 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt @@ -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 { diff --git a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt index 638f1566..b90ac27e 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt @@ -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 diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppDownloaderScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/AppDownloaderScreen.kt new file mode 100644 index 00000000..2b71e879 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/screen/AppDownloaderScreen.kt @@ -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 { + 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) } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt index 4cd845e9..d69c4459 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt @@ -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)) + } + } +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 963694d5..c589eb9a 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -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() diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index d94301f1..701654a1 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -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 + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppDownloaderViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppDownloaderViewModel.kt new file mode 100644 index 00000000..e2ce849a --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppDownloaderViewModel.kt @@ -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() + + 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() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt new file mode 100644 index 00000000..24cd51df --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -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 { + it.packageName + }.thenBy { it.version } + ) + } + + val selection = mutableStateSetOf() + + 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() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt index 83d3c39f..7b57b83e 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -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()) private val compatibleApps = MutableStateFlow(emptyList()) - val appList: StateFlow> = compatibleApps.combine(installedApps) { compatibleApps, installedApps -> + val appList: Flow> = 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, diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index 07f52a02..5409bfa1 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54dadd4e..048a70e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Settings Select an app Select patches + Select version General General settings @@ -56,7 +57,11 @@ Failed to backup patches selection: %s Clear patches selection Clear all patches selection - + Prefer split apks + Prefer split apks instead of full apks + Prefer universal apks + Prefer universal instead of arch-specific apks + Search apps… Loading… Downloading patch bundle… @@ -71,10 +76,12 @@ Help Back Add + Delete System Light Dark Appearance + Downloaded apps Device Android version Model @@ -87,6 +94,9 @@ Apps Sources Reload all sources + Continue anyways + Download another version + Download app Failed to download patch bundle: %s Failed to load updated patch bundle: %s Failed to update integrations: %s @@ -97,7 +107,16 @@ Supported Universal Unsupported - Some of the patches do not support this app version (%1$s). The patches only support the following versions: %2$s. + Some of the patches do not support this app version (%1$s). The patches only support the following version(s): %2$s. + Continue with this version? + Not all patches support this version (%s). Do you want to continue anyway? + Download application? + The app you selected isn\'t installed. Do you want to download it? + Failed to load apk + + An error occurred + Already downloaded + Downloading app… (%1$s MB/%2$s MB) Select file