mirror of
https://github.com/revanced/revanced-manager-compose
synced 2024-09-10 08:27:11 +02:00
refactor: PackageManager
(#31)
* refactor: refactor `PM` * feat: use plurals for patch count * fix: support apk's from storage * feat: use ViewModel for loading apps and bundles * fix: fix file selector that has no reason to be broken * refactor: rename parameter * refactor: `MainViewModel` * feat: make all apps use `path` * build: target java 11
This commit is contained in:
parent
a6b86691db
commit
cd0144b563
@ -27,12 +27,13 @@ android {
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/prebuilt/**"
|
||||
}
|
||||
@ -51,6 +52,10 @@ android {
|
||||
composeOptions.kotlinCompilerExtensionVersion = "1.4.7"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(11)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
// AndroidX Core
|
||||
@ -58,7 +63,7 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation("androidx.activity:activity-compose:1.7.1")
|
||||
implementation("androidx.activity:activity-compose:1.7.2")
|
||||
implementation("androidx.paging:paging-common-ktx:3.1.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
|
||||
@ -78,10 +83,12 @@ dependencies {
|
||||
//implementation("com.google.accompanist:accompanist-permissions:$accompanistVersion")
|
||||
|
||||
// Coil (async image loading, network image)
|
||||
implementation("io.coil-kt:coil-compose:2.3.0")
|
||||
implementation("io.coil-kt:coil-compose:2.4.0")
|
||||
implementation("me.zhanghai.android.appiconloader:appiconloader-coil:1.5.0")
|
||||
|
||||
// KotlinX
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
|
||||
|
||||
// Room
|
||||
val roomVersion = "2.5.1"
|
||||
@ -94,7 +101,7 @@ dependencies {
|
||||
implementation("app.revanced:revanced-patcher:9.0.0")
|
||||
|
||||
// Signing
|
||||
implementation("com.android.tools.build:apksig:8.1.0-beta02")
|
||||
implementation("com.android.tools.build:apksig:8.2.0-alpha05")
|
||||
implementation("org.bouncycastle:bcpkix-jdk15on:1.70")
|
||||
|
||||
// Koin
|
||||
|
@ -28,6 +28,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ReVancedManager"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:targetApi="33">
|
||||
|
||||
<activity
|
||||
|
@ -7,24 +7,32 @@ import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import app.revanced.manager.compose.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.compose.domain.repository.BundleRepository
|
||||
import app.revanced.manager.compose.ui.destination.Destination
|
||||
import app.revanced.manager.compose.ui.screen.*
|
||||
import app.revanced.manager.compose.ui.screen.AppSelectorScreen
|
||||
import app.revanced.manager.compose.ui.screen.DashboardScreen
|
||||
import app.revanced.manager.compose.ui.screen.InstallerScreen
|
||||
import app.revanced.manager.compose.ui.screen.PatchesSelectorScreen
|
||||
import app.revanced.manager.compose.ui.screen.SettingsScreen
|
||||
import app.revanced.manager.compose.ui.theme.ReVancedManagerTheme
|
||||
import app.revanced.manager.compose.ui.theme.Theme
|
||||
import app.revanced.manager.compose.util.PM
|
||||
import dev.olshevski.navigation.reimagined.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
import app.revanced.manager.compose.ui.viewmodel.MainViewModel
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
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.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.viewmodel.ext.android.getViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val prefs: PreferencesManager by inject()
|
||||
private val bundleRepository: BundleRepository by inject()
|
||||
private val mainScope = MainScope()
|
||||
private val prefs: PreferencesManager = get()
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@ -32,12 +40,18 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
installSplashScreen()
|
||||
|
||||
bundleRepository.onAppStart(this@MainActivity)
|
||||
getViewModel<MainViewModel>()
|
||||
|
||||
val context = this
|
||||
mainScope.launch(Dispatchers.IO) {
|
||||
PM.loadApps(context)
|
||||
}
|
||||
val scale = this.resources.displayMetrics.density
|
||||
val pixels = (36 * scale).roundToInt()
|
||||
Coil.setImageLoader(
|
||||
ImageLoader.Builder(this)
|
||||
.components {
|
||||
add(AppIconKeyer())
|
||||
add(AppIconFetcher.Factory(pixels, true, this@MainActivity))
|
||||
}
|
||||
.build()
|
||||
)
|
||||
|
||||
setContent {
|
||||
ReVancedManagerTheme(
|
||||
|
@ -1,10 +1,11 @@
|
||||
package app.revanced.manager.compose.di
|
||||
|
||||
import app.revanced.manager.compose.domain.repository.SourceRepository
|
||||
import app.revanced.manager.compose.patcher.SignerService
|
||||
import app.revanced.manager.compose.util.PM
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val managerModule = module {
|
||||
singleOf(::SignerService)
|
||||
singleOf(::PM)
|
||||
}
|
@ -2,7 +2,6 @@ package app.revanced.manager.compose.di
|
||||
|
||||
import app.revanced.manager.compose.domain.repository.ReVancedRepository
|
||||
import app.revanced.manager.compose.network.api.ManagerAPI
|
||||
import app.revanced.manager.compose.domain.repository.BundleRepository
|
||||
import app.revanced.manager.compose.domain.repository.SourcePersistenceRepository
|
||||
import app.revanced.manager.compose.domain.repository.SourceRepository
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
@ -11,7 +10,6 @@ import org.koin.dsl.module
|
||||
val repositoryModule = module {
|
||||
singleOf(::ReVancedRepository)
|
||||
singleOf(::ManagerAPI)
|
||||
singleOf(::BundleRepository)
|
||||
singleOf(::SourcePersistenceRepository)
|
||||
singleOf(::SourceRepository)
|
||||
}
|
@ -5,6 +5,7 @@ import org.koin.androidx.viewmodel.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val viewModelModule = module {
|
||||
viewModelOf(::MainViewModel)
|
||||
viewModelOf(::PatchesSelectorViewModel)
|
||||
viewModelOf(::SettingsViewModel)
|
||||
viewModelOf(::AppSelectorViewModel)
|
||||
|
@ -1,50 +0,0 @@
|
||||
package app.revanced.manager.compose.domain.repository
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.revanced.manager.compose.patcher.patch.PatchBundle
|
||||
import app.revanced.manager.compose.util.launchAndRepeatWithViewLifecycle
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BundleRepository(private val sourceRepository: SourceRepository) {
|
||||
/**
|
||||
* A [Flow] that emits whenever the sources change.
|
||||
*
|
||||
* The outer flow emits whenever the sources configuration changes.
|
||||
* The inner flow emits whenever one of the bundles update.
|
||||
*/
|
||||
private val sourceUpdates = sourceRepository.sources.map { sources ->
|
||||
sources.map { (name, source) ->
|
||||
source.bundle.map { bundle ->
|
||||
name to bundle
|
||||
}
|
||||
}.merge().buffer()
|
||||
}
|
||||
|
||||
private val _bundles = MutableStateFlow<Map<String, PatchBundle>>(emptyMap())
|
||||
|
||||
/**
|
||||
* A [Flow] that gives you all loaded [PatchBundle]s.
|
||||
* This is only synced when the app is in the foreground.
|
||||
*/
|
||||
val bundles = _bundles.asStateFlow()
|
||||
|
||||
fun onAppStart(lifecycleOwner: LifecycleOwner) {
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
sourceRepository.loadSources()
|
||||
}
|
||||
|
||||
lifecycleOwner.launchAndRepeatWithViewLifecycle {
|
||||
sourceUpdates.collect { events ->
|
||||
val map = HashMap<String, PatchBundle>()
|
||||
_bundles.emit(map)
|
||||
|
||||
events.collect { (name, new) ->
|
||||
map[name] = new
|
||||
_bundles.emit(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -4,14 +4,17 @@ import android.app.Application
|
||||
import android.util.Log
|
||||
import app.revanced.manager.compose.data.room.sources.SourceEntity
|
||||
import app.revanced.manager.compose.data.room.sources.SourceLocation
|
||||
import app.revanced.manager.compose.domain.sources.RemoteSource
|
||||
import app.revanced.manager.compose.domain.sources.LocalSource
|
||||
import app.revanced.manager.compose.domain.sources.RemoteSource
|
||||
import app.revanced.manager.compose.domain.sources.Source
|
||||
import app.revanced.manager.compose.util.tag
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
@ -20,6 +23,18 @@ import java.io.InputStream
|
||||
class SourceRepository(app: Application, private val persistenceRepo: SourcePersistenceRepository) {
|
||||
private val sourcesDir = app.dataDir.resolve("sources").also { it.mkdirs() }
|
||||
|
||||
private val _sources: MutableStateFlow<Map<String, Source>> = MutableStateFlow(emptyMap())
|
||||
val sources = _sources.asStateFlow()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val bundles = sources.flatMapLatest { sources ->
|
||||
combine(
|
||||
sources.map { (_, source) -> source.bundle }
|
||||
) { bundles ->
|
||||
sources.keys.zip(bundles).toMap()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory of the [Source] with the specified [uid], creating it if needed.
|
||||
*/
|
||||
@ -49,7 +64,7 @@ class SourceRepository(app: Application, private val persistenceRepo: SourcePers
|
||||
persistenceRepo.clear()
|
||||
_sources.emit(emptyMap())
|
||||
sourcesDir.apply {
|
||||
delete()
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
|
||||
@ -58,7 +73,7 @@ class SourceRepository(app: Application, private val persistenceRepo: SourcePers
|
||||
|
||||
suspend fun remove(source: Source) = withContext(Dispatchers.Default) {
|
||||
persistenceRepo.delete(source.id)
|
||||
directoryOf(source.id).delete()
|
||||
directoryOf(source.id).deleteRecursively()
|
||||
|
||||
_sources.update {
|
||||
it.filterValues { value ->
|
||||
@ -84,9 +99,6 @@ class SourceRepository(app: Application, private val persistenceRepo: SourcePers
|
||||
addSource(name, RemoteSource(id, directoryOf(id)))
|
||||
}
|
||||
|
||||
private val _sources: MutableStateFlow<Map<String, Source>> = MutableStateFlow(emptyMap())
|
||||
val sources = _sources.asStateFlow()
|
||||
|
||||
suspend fun redownloadRemoteSources() =
|
||||
sources.value.values.filterIsInstance<RemoteSource>().forEach { it.downloadLatest() }
|
||||
}
|
@ -18,8 +18,6 @@ class LocalSource(id: Int, directory: File) : Source(id, directory) {
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_bundle.emit(loadBundle { throw it })
|
||||
}
|
||||
_bundle.emit(loadBundle { throw it })
|
||||
}
|
||||
}
|
||||
|
@ -3,17 +3,15 @@ package app.revanced.manager.compose.domain.sources
|
||||
import app.revanced.manager.compose.network.api.ManagerAPI
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.component.get
|
||||
import java.io.File
|
||||
|
||||
class RemoteSource(id: Int, directory: File) : Source(id, directory) {
|
||||
private val api: ManagerAPI by inject()
|
||||
private val api: ManagerAPI = get()
|
||||
suspend fun downloadLatest() = withContext(Dispatchers.IO) {
|
||||
api.downloadBundle(patchesJar, integrations).also { (patchesVer, integrationsVer) ->
|
||||
saveVersion(patchesVer, integrationsVer)
|
||||
withContext(Dispatchers.Main) {
|
||||
_bundle.emit(loadBundle { err -> throw err })
|
||||
}
|
||||
_bundle.emit(loadBundle { err -> throw err })
|
||||
}
|
||||
|
||||
return@withContext
|
||||
|
@ -42,5 +42,5 @@ class PatchBundle(private val loader: Iterable<PatchClass>, val integrations: Fi
|
||||
true
|
||||
}
|
||||
|
||||
fun loadAllPatches() = loader.toList()
|
||||
private fun loadAllPatches() = loader.toList()
|
||||
}
|
@ -4,12 +4,13 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import app.revanced.manager.compose.domain.repository.BundleRepository
|
||||
import app.revanced.manager.compose.domain.repository.SourceRepository
|
||||
import app.revanced.manager.compose.patcher.Session
|
||||
import app.revanced.manager.compose.patcher.aapt.Aapt
|
||||
import app.revanced.manager.compose.util.PatchesSelection
|
||||
import app.revanced.manager.compose.util.tag
|
||||
import app.revanced.patcher.extensions.PatchExtensions.patchName
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.component.KoinComponent
|
||||
@ -20,7 +21,7 @@ import java.io.FileNotFoundException
|
||||
// TODO: setup wakelock + notification so android doesn't murder us.
|
||||
class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters),
|
||||
KoinComponent {
|
||||
private val bundleRepository: BundleRepository by inject()
|
||||
private val sourceRepository: SourceRepository by inject()
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
@ -50,7 +51,7 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineW
|
||||
|
||||
val args = Json.decodeFromString<Args>(inputData.getString(ARGS_KEY)!!)
|
||||
|
||||
val bundles = bundleRepository.bundles.value
|
||||
val bundles = sourceRepository.bundles.first()
|
||||
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
|
||||
|
||||
val patchList = args.selectedPatches.flatMap { (bundleName, selected) ->
|
||||
|
@ -6,6 +6,7 @@ import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class InstallService : Service() {
|
||||
|
||||
override fun onStartCommand(
|
||||
|
@ -6,6 +6,7 @@ import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class UninstallService : Service() {
|
||||
|
||||
override fun onStartCommand(
|
||||
|
@ -1,6 +1,5 @@
|
||||
package app.revanced.manager.compose.ui.component
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
@ -11,31 +10,30 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import app.revanced.manager.compose.util.AppInfo
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@Composable
|
||||
fun AppIcon(
|
||||
drawable: Drawable?,
|
||||
app: AppInfo,
|
||||
contentDescription: String?,
|
||||
size: Int = 48
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (drawable == null) {
|
||||
if (app.packageInfo == null) {
|
||||
val image = rememberVectorPainter(Icons.Default.Android)
|
||||
val colorFilter = ColorFilter.tint(LocalContentColor.current)
|
||||
|
||||
Image(
|
||||
image,
|
||||
contentDescription,
|
||||
Modifier.size(size.dp),
|
||||
Modifier.size(36.dp).then(modifier),
|
||||
colorFilter = colorFilter
|
||||
)
|
||||
} else {
|
||||
val image = rememberAsyncImagePainter(drawable)
|
||||
|
||||
Image(
|
||||
image,
|
||||
AsyncImage(
|
||||
app.packageInfo,
|
||||
contentDescription,
|
||||
Modifier.size(size.dp)
|
||||
Modifier.size(36.dp).then(modifier)
|
||||
)
|
||||
}
|
||||
}
|
@ -11,10 +11,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.revanced.manager.compose.R
|
||||
|
||||
@Composable
|
||||
fun LoadingIndicator(progress: Float? = null, text: Int? = R.string.loading_body) {
|
||||
fun LoadingIndicator(progress: Float? = null, text: Int? = null) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
|
@ -1,7 +1,7 @@
|
||||
package app.revanced.manager.compose.ui.destination
|
||||
|
||||
import android.os.Parcelable
|
||||
import app.revanced.manager.compose.util.PackageInfo
|
||||
import app.revanced.manager.compose.util.AppInfo
|
||||
import app.revanced.manager.compose.util.PatchesSelection
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ -17,8 +17,8 @@ sealed interface Destination : Parcelable {
|
||||
object Settings : Destination
|
||||
|
||||
@Parcelize
|
||||
data class PatchesSelector(val input: PackageInfo) : Destination
|
||||
data class PatchesSelector(val input: AppInfo) : Destination
|
||||
|
||||
@Parcelize
|
||||
data class Installer(val input: PackageInfo, val selectedPatches: PatchesSelection) : Destination
|
||||
data class Installer(val input: AppInfo, val selectedPatches: PatchesSelection) : Destination
|
||||
}
|
@ -19,32 +19,44 @@ 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.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.compose.R
|
||||
import app.revanced.manager.compose.ui.component.AppIcon
|
||||
import app.revanced.manager.compose.ui.component.AppTopBar
|
||||
import app.revanced.manager.compose.ui.component.LoadingIndicator
|
||||
import app.revanced.manager.compose.ui.viewmodel.AppSelectorViewModel
|
||||
import app.revanced.manager.compose.util.APK_MIMETYPE
|
||||
import app.revanced.manager.compose.util.PM
|
||||
import app.revanced.manager.compose.util.PackageInfo
|
||||
import app.revanced.manager.compose.util.AppInfo
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppSelectorScreen(
|
||||
onAppClick: (PackageInfo) -> Unit,
|
||||
onAppClick: (AppInfo) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
vm: AppSelectorViewModel = getViewModel()
|
||||
) {
|
||||
val pickApkLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { apkUri ->
|
||||
vm.loadSelectedFile(apkUri!!).let(onAppClick)
|
||||
}
|
||||
val pickApkLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
|
||||
it?.let { apkUri -> onAppClick(vm.loadSelectedFile(apkUri)) }
|
||||
}
|
||||
|
||||
var filterText by rememberSaveable { mutableStateOf("") }
|
||||
var search by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val appList by vm.appList.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val filteredAppList = rememberSaveable(appList, filterText) {
|
||||
appList.filter { app ->
|
||||
(vm.loadLabel(app.packageInfo)).contains(
|
||||
filterText,
|
||||
true
|
||||
) or app.packageName.contains(filterText, true)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: find something better for this
|
||||
if (search) {
|
||||
SearchBar(
|
||||
@ -63,34 +75,30 @@ fun AppSelectorScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = SearchBarDefaults.inputFieldShape,
|
||||
content = {
|
||||
if (PM.appList.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (appList.isNotEmpty()) {
|
||||
items(
|
||||
PM.appList
|
||||
.filter { app ->
|
||||
(app.label.contains(
|
||||
filterText,
|
||||
true
|
||||
) or app.packageName.contains(filterText, true))
|
||||
}
|
||||
items = filteredAppList,
|
||||
key = { it.packageName }
|
||||
) { app ->
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onAppClick(PackageInfo(app)) },
|
||||
leadingContent = { AppIcon(app.icon, null, 36) },
|
||||
headlineContent = { Text(app.label) },
|
||||
modifier = Modifier.clickable {
|
||||
app.packageInfo?.let { onAppClick(app) }
|
||||
},
|
||||
leadingContent = { AppIcon(app, null) },
|
||||
headlineContent = { Text(vm.loadLabel(app.packageInfo)) },
|
||||
supportingContent = { Text(app.packageName) },
|
||||
trailingContent = { Text("420 Patches") }
|
||||
trailingContent = if (app.patches > 0) { { Text(pluralStringResource(R.plurals.patches_count, app.patches, app.patches)) } } else null
|
||||
)
|
||||
|
||||
}
|
||||
} else {
|
||||
item { LoadingIndicator() }
|
||||
}
|
||||
} else {
|
||||
LoadingIndicator()
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -112,99 +120,48 @@ fun AppSelectorScreen(
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues)
|
||||
) {
|
||||
if (PM.supportedAppList.isNotEmpty()) {
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
pickApkLauncher.launch(APK_MIMETYPE)
|
||||
},
|
||||
leadingContent = {
|
||||
Box(Modifier.size(36.dp), Alignment.Center) {
|
||||
Icon(
|
||||
Icons.Default.Storage,
|
||||
null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
headlineContent = { Text(stringResource(R.string.select_from_storage)) }
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
}
|
||||
|
||||
(PM.appList.ifEmpty { PM.supportedAppList }).also { list ->
|
||||
items(
|
||||
count = list.size,
|
||||
key = { list[it].packageName }
|
||||
) { index ->
|
||||
|
||||
val app = list[index]
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onAppClick(PackageInfo(app)) },
|
||||
leadingContent = { AppIcon(app.icon, null, 36) },
|
||||
headlineContent = { Text(app.label) },
|
||||
supportingContent = { Text(app.packageName) },
|
||||
trailingContent = {
|
||||
Text("420 Patches")
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
pickApkLauncher.launch(APK_MIMETYPE)
|
||||
},
|
||||
leadingContent = {
|
||||
Box(Modifier.size(36.dp), Alignment.Center) {
|
||||
Icon(
|
||||
Icons.Default.Storage,
|
||||
null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
}
|
||||
},
|
||||
headlineContent = { Text(stringResource(R.string.select_from_storage)) }
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
if (appList.isNotEmpty()) {
|
||||
items(
|
||||
items = appList,
|
||||
key = { it.packageName }
|
||||
) { app ->
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
app.packageInfo?.let { onAppClick(app) }
|
||||
},
|
||||
leadingContent = { AppIcon(app, null) },
|
||||
headlineContent = { Text(vm.loadLabel(app.packageInfo)) },
|
||||
supportingContent = { Text(app.packageName) },
|
||||
trailingContent = if (app.patches > 0) { { Text(pluralStringResource(R.plurals.patches_count, app.patches, app.patches)) } } else null
|
||||
)
|
||||
|
||||
if (PM.appList.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth(), Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
Modifier.padding(vertical = 15.dp).size(24.dp),
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LoadingIndicator()
|
||||
item { LoadingIndicator() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = {},
|
||||
label = { Text("Patched apps") },
|
||||
leadingIcon = { Icon(Icons.Default.Check, null) },
|
||||
enabled = false
|
||||
)
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = {},
|
||||
label = { Text("User apps") },
|
||||
leadingIcon = { Icon(Icons.Default.Android, null) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterSystemApps,
|
||||
onClick = { filterSystemApps = !filterSystemApps },
|
||||
label = { Text("System apps") },
|
||||
leadingIcon = { Icon(Icons.Default.Apps, null) }
|
||||
)
|
||||
}*/
|
||||
|
@ -1,18 +1,28 @@
|
||||
package app.revanced.manager.compose.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageInfo
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import app.revanced.manager.compose.util.PM
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class AppSelectorViewModel(private val app: Application) : ViewModel() {
|
||||
class AppSelectorViewModel(
|
||||
private val app: Application,
|
||||
private val pm: PM
|
||||
) : ViewModel() {
|
||||
private val packageManager = app.packageManager
|
||||
|
||||
val appList = pm.appList
|
||||
|
||||
fun loadLabel(app: PackageInfo?) = (app?.applicationInfo?.loadLabel(packageManager) ?: "Not installed").toString()
|
||||
|
||||
fun loadSelectedFile(uri: Uri) =
|
||||
app.contentResolver.openInputStream(uri)!!.use { stream ->
|
||||
File(app.cacheDir, "input.apk").also {
|
||||
if (it.exists()) it.delete()
|
||||
Files.copy(stream, it.toPath())
|
||||
}.let { PM.getApkInfo(it, app) }
|
||||
}.let { pm.getApkInfo(it) }
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@ -21,8 +22,8 @@ import app.revanced.manager.compose.patcher.worker.PatcherWorker
|
||||
import app.revanced.manager.compose.patcher.worker.StepGroup
|
||||
import app.revanced.manager.compose.service.InstallService
|
||||
import app.revanced.manager.compose.service.UninstallService
|
||||
import app.revanced.manager.compose.util.AppInfo
|
||||
import app.revanced.manager.compose.util.PM
|
||||
import app.revanced.manager.compose.util.PackageInfo
|
||||
import app.revanced.manager.compose.util.PatchesSelection
|
||||
import app.revanced.manager.compose.util.toast
|
||||
import kotlinx.serialization.encodeToString
|
||||
@ -33,11 +34,13 @@ import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class InstallerScreenViewModel(
|
||||
input: PackageInfo,
|
||||
input: AppInfo,
|
||||
selectedPatches: PatchesSelection
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val signerService: SignerService by inject()
|
||||
private val app: Application by inject()
|
||||
private val pm: PM by inject()
|
||||
|
||||
var stepGroups by mutableStateOf<List<StepGroup>>(
|
||||
PatcherProgressManager.generateGroupsList(
|
||||
app,
|
||||
@ -45,7 +48,7 @@ class InstallerScreenViewModel(
|
||||
)
|
||||
private set
|
||||
|
||||
val packageName = input.packageName
|
||||
val packageName: String = input.packageName
|
||||
|
||||
private val workManager = WorkManager.getInstance(app)
|
||||
|
||||
@ -69,11 +72,11 @@ class InstallerScreenViewModel(
|
||||
PatcherWorker.ARGS_KEY to
|
||||
Json.Default.encodeToString(
|
||||
PatcherWorker.Args(
|
||||
input.apk.path,
|
||||
input.path!!.absolutePath,
|
||||
outputFile.path,
|
||||
selectedPatches,
|
||||
input.packageName,
|
||||
input.version,
|
||||
input.packageInfo!!.versionName,
|
||||
)
|
||||
)
|
||||
)
|
||||
@ -141,7 +144,7 @@ class InstallerScreenViewModel(
|
||||
isInstalling = true
|
||||
try {
|
||||
if (!signApk()) return
|
||||
PM.installApp(listOf(signedFile), app)
|
||||
pm.installApp(listOf(signedFile))
|
||||
} finally {
|
||||
isInstalling = false
|
||||
}
|
||||
|
@ -0,0 +1,27 @@
|
||||
package app.revanced.manager.compose.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.revanced.manager.compose.domain.repository.SourceRepository
|
||||
import app.revanced.manager.compose.util.PM
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainViewModel(
|
||||
sourceRepository: SourceRepository,
|
||||
pm: PM
|
||||
) : ViewModel() {
|
||||
init {
|
||||
with(viewModelScope) {
|
||||
launch {
|
||||
sourceRepository.loadSources()
|
||||
}
|
||||
launch {
|
||||
pm.getCompatibleApps()
|
||||
}
|
||||
launch(Dispatchers.IO) {
|
||||
pm.getInstalledApps()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,23 +1,26 @@
|
||||
package app.revanced.manager.compose.ui.viewmodel
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import app.revanced.manager.compose.domain.repository.BundleRepository
|
||||
import app.revanced.manager.compose.domain.repository.SourceRepository
|
||||
import app.revanced.manager.compose.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.compose.util.PackageInfo
|
||||
import app.revanced.manager.compose.util.AppInfo
|
||||
import app.revanced.manager.compose.util.PatchesSelection
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
class PatchesSelectorViewModel(packageInfo: PackageInfo) : ViewModel(), KoinComponent {
|
||||
val bundlesFlow = get<BundleRepository>().bundles.map { bundles ->
|
||||
class PatchesSelectorViewModel(appInfo: AppInfo) : ViewModel(), KoinComponent {
|
||||
val bundlesFlow = get<SourceRepository>().bundles.map { bundles ->
|
||||
bundles.mapValues { (_, bundle) -> bundle.patches }.map { (name, patches) ->
|
||||
val supported = mutableListOf<PatchInfo>()
|
||||
val unsupported = mutableListOf<PatchInfo>()
|
||||
|
||||
patches.filter { it.compatibleWith(packageInfo.packageName) }.forEach {
|
||||
val targetList = if (it.supportsVersion(packageInfo.packageName)) supported else unsupported
|
||||
patches.filter { it.compatibleWith(appInfo.packageName) }.forEach {
|
||||
val targetList = if (it.supportsVersion(appInfo.packageInfo!!.versionName)) supported else unsupported
|
||||
|
||||
targetList.add(it)
|
||||
}
|
||||
|
@ -1,18 +1,17 @@
|
||||
package app.revanced.manager.compose.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Environment
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.revanced.manager.compose.network.api.ManagerAPI
|
||||
import app.revanced.manager.compose.util.PM
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import app.revanced.manager.compose.util.PM
|
||||
import java.io.File
|
||||
|
||||
class UpdateSettingsViewModel(
|
||||
private val managerAPI: ManagerAPI,
|
||||
private val app: Application,
|
||||
private val pm: PM
|
||||
) : ViewModel() {
|
||||
val downloadProgress get() = (managerAPI.downloadProgress?.times(100)) ?: 0f
|
||||
val downloadedSize get() = managerAPI.downloadedSize ?: 0L
|
||||
@ -23,18 +22,16 @@ class UpdateSettingsViewModel(
|
||||
}
|
||||
}
|
||||
fun installUpdate() {
|
||||
PM.installApp(
|
||||
pm.installApp(
|
||||
apks = listOf(
|
||||
File(
|
||||
(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS + "/revanced-manager.apk")
|
||||
.toString())
|
||||
),
|
||||
),
|
||||
context = app,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
downloadLatestManager()
|
||||
}
|
||||
|
@ -1,74 +1,133 @@
|
||||
package app.revanced.manager.compose.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.Immutable
|
||||
import app.revanced.manager.compose.domain.repository.SourceRepository
|
||||
import app.revanced.manager.compose.service.InstallService
|
||||
import app.revanced.manager.compose.service.UninstallService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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 kotlinx.parcelize.RawValue
|
||||
import java.io.File
|
||||
|
||||
private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readable
|
||||
|
||||
@Immutable
|
||||
@Parcelize
|
||||
data class PackageInfo(val packageName: String, val version: String, val apk: File) : Parcelable {
|
||||
constructor(appInfo: PM.AppInfo) : this(appInfo.packageName, appInfo.versionName, appInfo.apk)
|
||||
}
|
||||
data class AppInfo(
|
||||
val packageName: String,
|
||||
val patches: Int,
|
||||
val packageInfo: PackageInfo?,
|
||||
val path: File? = null
|
||||
) : Parcelable
|
||||
|
||||
@SuppressLint("QueryPermissionsNeeded")
|
||||
@Suppress("DEPRECATION")
|
||||
object PM {
|
||||
val appList = mutableStateListOf<AppInfo>()
|
||||
val supportedAppList = mutableStateListOf<AppInfo>()
|
||||
class PM(
|
||||
private val app: Application,
|
||||
private val sourceRepository: SourceRepository
|
||||
) {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
suspend fun loadApps(context: Context) {
|
||||
val packageManager = context.packageManager
|
||||
private val installedApps = MutableStateFlow(emptyList<AppInfo>())
|
||||
private val compatibleApps = MutableStateFlow(emptyList<AppInfo>())
|
||||
|
||||
val localAppList = mutableListOf<AppInfo>()
|
||||
val appList: StateFlow<List<AppInfo>> = compatibleApps.combine(installedApps) { compatibleApps, installedApps ->
|
||||
if (compatibleApps.isNotEmpty()) {
|
||||
(compatibleApps + installedApps)
|
||||
.distinctBy { it.packageName }
|
||||
.sortedWith(
|
||||
compareByDescending<AppInfo> {
|
||||
it.patches
|
||||
}.thenBy { it.packageInfo?.applicationInfo?.loadLabel(app.packageManager).toString() }.thenBy { it.packageName }
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
packageManager.getInstalledApplications(PackageManager.GET_META_DATA).map {
|
||||
AppInfo(
|
||||
it.packageName,
|
||||
"0.69.420",
|
||||
it.loadLabel(packageManager).toString(),
|
||||
it.loadIcon(packageManager),
|
||||
File("h")
|
||||
)
|
||||
}.also { localAppList.addAll(it) }.also { supportedAppList.addAll(it) }
|
||||
}
|
||||
suspend fun getCompatibleApps() {
|
||||
sourceRepository.bundles.collect { bundles ->
|
||||
val compatiblePackages = HashMap<String, Int>()
|
||||
|
||||
@Parcelize
|
||||
data class AppInfo(
|
||||
val packageName: String,
|
||||
val versionName: String,
|
||||
val label: String,
|
||||
val icon: @RawValue Drawable? = null,
|
||||
val apk: File,
|
||||
) : Parcelable
|
||||
bundles.flatMap { it.value.patches }.forEach {
|
||||
it.compatiblePackages?.forEach { pkg ->
|
||||
compatiblePackages[pkg.name] = compatiblePackages.getOrDefault(pkg.name, 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
fun installApp(apks: List<File>, context: Context) {
|
||||
val packageInstaller = context.packageManager.packageInstaller
|
||||
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session ->
|
||||
apks.forEach { apk -> session.writeApk(apk) }
|
||||
session.commit(context.installIntentSender)
|
||||
withContext(Dispatchers.IO) {
|
||||
compatibleApps.emit(
|
||||
compatiblePackages.keys.map { pkg ->
|
||||
try {
|
||||
val packageInfo = app.packageManager.getPackageInfo(pkg, 0)
|
||||
AppInfo(
|
||||
pkg,
|
||||
compatiblePackages[pkg] ?: 0,
|
||||
packageInfo,
|
||||
File(packageInfo.applicationInfo.sourceDir)
|
||||
)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
AppInfo(
|
||||
pkg,
|
||||
compatiblePackages[pkg] ?: 0,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallPackage(pkg: String, context: Context) {
|
||||
val packageInstaller = context.packageManager.packageInstaller
|
||||
packageInstaller.uninstall(pkg, context.uninstallIntentSender)
|
||||
suspend fun getInstalledApps() {
|
||||
installedApps.emit(app.packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES).map { packageInfo ->
|
||||
AppInfo(
|
||||
packageInfo.packageName,
|
||||
0,
|
||||
packageInfo,
|
||||
File(packageInfo.applicationInfo.sourceDir)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fun getApkInfo(apk: File, context: Context) = context.packageManager.getPackageArchiveInfo(apk.path, 0)!!
|
||||
.let { PackageInfo(it.packageName, it.versionName, apk) }
|
||||
fun installApp(apks: List<File>) {
|
||||
val packageInstaller = app.packageManager.packageInstaller
|
||||
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session ->
|
||||
apks.forEach { apk -> session.writeApk(apk) }
|
||||
session.commit(app.installIntentSender)
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallPackage(pkg: String) {
|
||||
val packageInstaller = app.packageManager.packageInstaller
|
||||
packageInstaller.uninstall(pkg, app.uninstallIntentSender)
|
||||
}
|
||||
|
||||
fun getApkInfo(apk: File) = app.packageManager.getPackageArchiveInfo(apk.path, 0)!!.let {
|
||||
AppInfo(
|
||||
it.packageName,
|
||||
0,
|
||||
it,
|
||||
apk
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PackageInstaller.Session.writeApk(apk: File) {
|
||||
|
@ -62,7 +62,7 @@
|
||||
<string name="no_patched_apps_found">No patched apps found</string>
|
||||
<string name="unsupported_app">Unsupported app</string>
|
||||
<string name="unsupported_patches">Unsupported patches</string>
|
||||
<string name="app_not_supported">Some of the patches do not support this app version (%s). The patches only support the following versions: %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 versions: %2$s.</string>
|
||||
|
||||
<string name="installer">Installer</string>
|
||||
<string name="install_app">Install</string>
|
||||
|
Loading…
Reference in New Issue
Block a user