feat: store patched apps (#79)

* feat: store patched apps

* fix: missing string

* feat: save patch selection

* feat: things

* fix: fix broken query

* fix: remove redundant `withContext`

* fix: fix
This commit is contained in:
Robert 2023-08-17 17:42:10 +02:00 committed by GitHub
parent ac4c7e06e7
commit a0b92554e9
33 changed files with 842 additions and 89 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "7142188e25ce489eb233aed8fb76e4cc",
"identityHash": "5515d164bc8f713201506d42a02d337f",
"entities": [
{
"tableName": "patch_bundles",
@ -190,12 +190,117 @@
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "installed_app",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`current_package_name` TEXT NOT NULL, `original_package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `install_type` TEXT NOT NULL, PRIMARY KEY(`current_package_name`))",
"fields": [
{
"fieldPath": "currentPackageName",
"columnName": "current_package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "originalPackageName",
"columnName": "original_package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "installType",
"columnName": "install_type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"current_package_name"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "applied_patch",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bundle",
"columnName": "bundle",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patchName",
"columnName": "patch_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"package_name",
"bundle",
"patch_name"
]
},
"indices": [
{
"name": "index_applied_patch_bundle",
"unique": false,
"columnNames": [
"bundle"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_applied_patch_bundle` ON `${TABLE_NAME}` (`bundle`)"
}
],
"foreignKeys": [
{
"table": "installed_app",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"package_name"
],
"referencedColumns": [
"current_package_name"
]
},
{
"table": "patch_bundles",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"bundle"
],
"referencedColumns": [
"uid"
]
}
]
}
],
"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, '7142188e25ce489eb233aed8fb76e4cc')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5515d164bc8f713201506d42a02d337f')"
]
}
}

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.screen.AppInfoScreen
import app.revanced.manager.ui.screen.VersionSelectorScreen
import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen
@ -18,19 +19,14 @@ import app.revanced.manager.ui.screen.SettingsScreen
import app.revanced.manager.ui.theme.ReVancedManagerTheme
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.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.popUpTo
import dev.olshevski.navigation.reimagined.rememberNavController
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
import org.koin.androidx.compose.getViewModel
import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
class MainActivity : ComponentActivity() {
@ -42,17 +38,6 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
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 {
val theme by vm.prefs.theme.getAsState()
val dynamicColor by vm.prefs.dynamicColor.getAsState()
@ -77,7 +62,16 @@ class MainActivity : ComponentActivity() {
when (destination) {
is Destination.Dashboard -> DashboardScreen(
onSettingsClick = { navController.navigate(Destination.Settings) },
onAppSelectorClick = { navController.navigate(Destination.AppSelector) }
onAppSelectorClick = { navController.navigate(Destination.AppSelector) },
onAppClick = { installedApp -> navController.navigate(Destination.ApplicationInfo(installedApp)) }
)
is Destination.ApplicationInfo -> AppInfoScreen(
onPatchClick = { packageName, patchesSelection ->
navController.navigate(Destination.VersionSelector(packageName, patchesSelection))
},
onBackClick = { navController.pop() },
viewModel = getViewModel { parametersOf(destination.installedApp) }
)
is Destination.Settings -> SettingsScreen(
@ -92,8 +86,15 @@ class MainActivity : ComponentActivity() {
is Destination.VersionSelector -> VersionSelectorScreen(
onBackClick = { navController.pop() },
onAppClick = { navController.navigate(Destination.PatchesSelector(it)) },
viewModel = getViewModel { parametersOf(destination.packageName) }
onAppClick = { selectedApp ->
navController.navigate(
Destination.PatchesSelector(
selectedApp,
destination.patchesSelection
)
)
},
viewModel = getViewModel { parametersOf(destination.packageName, destination.patchesSelection) }
)
is Destination.PatchesSelector -> PatchesSelectorScreen(
@ -107,7 +108,7 @@ class MainActivity : ComponentActivity() {
)
)
},
vm = getViewModel { parametersOf(destination.selectedApp) }
vm = getViewModel { parametersOf(destination) }
)
is Destination.Installer -> InstallerScreen(

View file

@ -5,8 +5,12 @@ import app.revanced.manager.di.*
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import kotlinx.coroutines.Dispatchers
import coil.Coil
import coil.ImageLoader
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
@ -36,6 +40,16 @@ class ManagerApplication : Application() {
)
}
val pixels = 512
Coil.setImageLoader(
ImageLoader.Builder(this)
.components {
add(AppIconKeyer())
add(AppIconFetcher.Factory(pixels, true, this@ManagerApplication))
}
.build()
)
scope.launch {
prefs.preload()
}

View file

@ -3,8 +3,11 @@ 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.apps.downloaded.DownloadedAppDao
import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
import app.revanced.manager.data.room.apps.installed.AppliedPatch
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.data.room.apps.installed.InstalledAppDao
import app.revanced.manager.data.room.selection.PatchSelection
import app.revanced.manager.data.room.selection.SelectedPatch
import app.revanced.manager.data.room.selection.SelectionDao
@ -12,12 +15,13 @@ import app.revanced.manager.data.room.bundles.PatchBundleDao
import app.revanced.manager.data.room.bundles.PatchBundleEntity
import kotlin.random.Random
@Database(entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class], version = 1)
@Database(entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun patchBundleDao(): PatchBundleDao
abstract fun selectionDao(): SelectionDao
abstract fun appDao(): AppDao
abstract fun downloadedAppDao(): DownloadedAppDao
abstract fun installedAppDao(): InstalledAppDao
companion object {
fun generateUid() = Random.Default.nextInt()

View file

@ -1,4 +1,4 @@
package app.revanced.manager.data.room.apps
package app.revanced.manager.data.room.apps.downloaded
import androidx.room.ColumnInfo
import androidx.room.Entity

View file

@ -1,4 +1,4 @@
package app.revanced.manager.data.room.apps
package app.revanced.manager.data.room.apps.downloaded
import androidx.room.Dao
import androidx.room.Delete
@ -7,7 +7,7 @@ import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface AppDao {
interface DownloadedAppDao {
@Query("SELECT * FROM downloaded_app")
fun getAllApps(): Flow<List<DownloadedApp>>

View file

@ -0,0 +1,34 @@
package app.revanced.manager.data.room.apps.installed
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import app.revanced.manager.data.room.bundles.PatchBundleEntity
import kotlinx.parcelize.Parcelize
@Parcelize
@Entity(
tableName = "applied_patch",
primaryKeys = ["package_name", "bundle", "patch_name"],
foreignKeys = [
ForeignKey(
InstalledApp::class,
parentColumns = ["current_package_name"],
childColumns = ["package_name"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
PatchBundleEntity::class,
parentColumns = ["uid"],
childColumns = ["bundle"]
)
],
indices = [Index(value = ["bundle"], unique = false)]
)
data class AppliedPatch(
@ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "bundle") val bundle: Int,
@ColumnInfo(name = "patch_name") val patchName: String
) : Parcelable

View file

@ -0,0 +1,23 @@
package app.revanced.manager.data.room.apps.installed
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import app.revanced.manager.R
import kotlinx.parcelize.Parcelize
enum class InstallType(val stringResource: Int) {
DEFAULT(R.string.default_install),
ROOT(R.string.root_install)
}
@Parcelize
@Entity(tableName = "installed_app")
data class InstalledApp(
@PrimaryKey
@ColumnInfo(name = "current_package_name") val currentPackageName: String,
@ColumnInfo(name = "original_package_name") val originalPackageName: String,
@ColumnInfo(name = "version") val version: String,
@ColumnInfo(name = "install_type") val installType: InstallType
) : Parcelable

View file

@ -0,0 +1,40 @@
package app.revanced.manager.data.room.apps.installed
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.MapInfo
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface InstalledAppDao {
@Query("SELECT * FROM installed_app")
fun getAll(): Flow<List<InstalledApp>>
@Query("SELECT * FROM installed_app WHERE current_package_name = :packageName")
suspend fun get(packageName: String): InstalledApp?
@MapInfo(keyColumn = "bundle", valueColumn = "patch_name")
@Query(
"SELECT bundle, patch_name FROM applied_patch" +
" WHERE package_name = :packageName"
)
suspend fun getPatchesSelection(packageName: String): Map<Int, List<String>>
@Transaction
suspend fun insertApp(installedApp: InstalledApp, appliedPatches: List<AppliedPatch>) {
insertApp(installedApp)
insertAppliedPatches(appliedPatches)
}
@Insert
suspend fun insertApp(installedApp: InstalledApp)
@Insert
suspend fun insertAppliedPatches(appliedPatches: List<AppliedPatch>)
@Delete
suspend fun delete(installedApp: InstalledApp)
}

View file

@ -18,4 +18,5 @@ val repositoryModule = module {
singleOf(::PatchBundleRepository)
singleOf(::WorkerRepository)
singleOf(::DownloadedAppRepository)
singleOf(::InstalledAppRepository)
}

View file

@ -19,4 +19,6 @@ val viewModelModule = module {
viewModelOf(::ImportExportViewModel)
viewModelOf(::ContributorViewModel)
viewModelOf(::DownloadsViewModel)
viewModelOf(::InstalledAppsViewModel)
viewModelOf(::AppInfoViewModel)
}

View file

@ -1,14 +1,14 @@
package app.revanced.manager.domain.repository
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.apps.DownloadedApp
import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
import kotlinx.coroutines.flow.distinctUntilChanged
import java.io.File
class DownloadedAppRepository(
db: AppDatabase
) {
private val dao = db.appDao()
private val dao = db.downloadedAppDao()
fun getAll() = dao.getAllApps().distinctUntilChanged()

View file

@ -0,0 +1,51 @@
package app.revanced.manager.domain.repository
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.apps.installed.AppliedPatch
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.util.PatchesSelection
import kotlinx.coroutines.flow.distinctUntilChanged
class InstalledAppRepository(
db: AppDatabase
) {
private val dao = db.installedAppDao()
fun getAll() = dao.getAll().distinctUntilChanged()
suspend fun get(packageName: String) = dao.get(packageName)
suspend fun getAppliedPatches(packageName: String): PatchesSelection =
dao.getPatchesSelection(packageName).mapValues { (_, patches) -> patches.toSet() }
suspend fun add(
currentPackageName: String,
originalPackageName: String,
version: String,
installType: InstallType,
patchesSelection: PatchesSelection
) {
dao.insertApp(
InstalledApp(
currentPackageName = currentPackageName,
originalPackageName = originalPackageName,
version = version,
installType = installType
),
patchesSelection.flatMap { (uid, patches) ->
patches.map { patch ->
AppliedPatch(
packageName = currentPackageName,
bundle = uid,
patchName = patch
)
}
}
)
}
suspend fun delete(installedApp: InstalledApp) {
dao.delete(installedApp)
}
}

View file

@ -14,7 +14,10 @@ class UninstallService : Service() {
flags: Int,
startId: Int
): Int {
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)) {
val extraStatus = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)
val extraStatusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
when (extraStatus) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
startActivity(if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
@ -28,6 +31,9 @@ class UninstallService : Service() {
else -> {
sendBroadcast(Intent().apply {
action = APP_UNINSTALL_ACTION
putExtra(EXTRA_UNINSTALL_STATUS, extraStatus)
putExtra(EXTRA_UNINSTALL_STATUS_MESSAGE, extraStatusMessage)
})
}
}
@ -39,6 +45,9 @@ class UninstallService : Service() {
companion object {
const val APP_UNINSTALL_ACTION = "APP_UNINSTALL_ACTION"
const val EXTRA_UNINSTALL_STATUS = "EXTRA_UNINSTALL_STATUS"
const val EXTRA_UNINSTALL_STATUS_MESSAGE = "EXTRA_INSTALL_STATUS_MESSAGE"
}
}

View file

@ -25,7 +25,7 @@ fun AppLabel(
packageInfo: PackageInfo?,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
defaultText: String = stringResource(R.string.not_installed)
defaultText: String? = stringResource(R.string.not_installed)
) {
val context = LocalContext.current

View file

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

View file

@ -0,0 +1,68 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* Credits to [Vendetta](https://github.com/vendetta-mod)
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RowScope.SegmentedButton(
icon: Any,
iconDescription: String? = null,
text: String,
onClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
modifier = Modifier
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.weight(1f)
.padding(vertical = 20.dp)
) {
when (icon) {
is ImageVector -> {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary
)
}
is Painter -> {
Icon(
painter = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary
)
}
}
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier = Modifier.basicMarquee()
)
}
}

View file

@ -1,6 +1,7 @@
package app.revanced.manager.ui.destination
import android.os.Parcelable
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection
@ -12,6 +13,9 @@ sealed interface Destination : Parcelable {
@Parcelize
object Dashboard : Destination
@Parcelize
data class ApplicationInfo(val installedApp: InstalledApp) : Destination
@Parcelize
object AppSelector : Destination
@ -19,11 +23,12 @@ sealed interface Destination : Parcelable {
object Settings : Destination
@Parcelize
data class VersionSelector(val packageName: String) : Destination
data class VersionSelector(val packageName: String, val patchesSelection: PatchesSelection? = null) : Destination
@Parcelize
data class PatchesSelector(val selectedApp: SelectedApp) : Destination
data class PatchesSelector(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
@Parcelize
data class Installer(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination
}

View file

@ -0,0 +1,158 @@
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowRight
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.SegmentedButton
import app.revanced.manager.ui.viewmodel.AppInfoViewModel
import app.revanced.manager.util.PatchesSelection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppInfoScreen(
onPatchClick: (packageName: String, patchesSelection: PatchesSelection) -> Unit,
onBackClick: () -> Unit,
viewModel: AppInfoViewModel
) {
SideEffect {
viewModel.onBackClick = onBackClick
}
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.app_info),
onBackClick = onBackClick
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppIcon(
viewModel.appInfo,
contentDescription = null,
modifier = Modifier
.size(100.dp)
.padding(bottom = 5.dp)
)
AppLabel(
viewModel.appInfo,
style = MaterialTheme.typography.titleLarge,
defaultText = null
)
Text(viewModel.installedApp.version, style = MaterialTheme.typography.bodySmall)
}
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(24.dp))
) {
SegmentedButton(
icon = Icons.Outlined.OpenInNew,
text = stringResource(R.string.open_app),
onClick = viewModel::launch
)
SegmentedButton(
icon = Icons.Outlined.Delete,
text = stringResource(R.string.uninstall),
onClick = viewModel::uninstall
)
SegmentedButton(
icon = Icons.Outlined.Update,
text = stringResource(R.string.repatch),
onClick = {
viewModel.appliedPatches?.let {
onPatchClick(viewModel.installedApp.originalPackageName, it)
}
}
)
}
Column(
modifier = Modifier.padding(vertical = 16.dp)
) {
ListItem(
modifier = Modifier.clickable { },
headlineContent = { Text(stringResource(R.string.applied_patches)) },
supportingContent = {
Text(
(viewModel.appliedPatches?.values?.sumOf { it.size } ?: 0).let {
pluralStringResource(
id = R.plurals.applied_patches,
it,
it
)
}
)
},
trailingContent = { Icon(Icons.Filled.ArrowRight, contentDescription = stringResource(R.string.view_applied_patches)) }
)
ListItem(
headlineContent = { Text(stringResource(R.string.package_name)) },
supportingContent = { Text(viewModel.installedApp.currentPackageName) }
)
if (viewModel.installedApp.originalPackageName != viewModel.installedApp.currentPackageName) {
ListItem(
headlineContent = { Text(stringResource(R.string.original_package_name)) },
supportingContent = { Text(viewModel.installedApp.originalPackageName) }
)
}
ListItem(
headlineContent = { Text(stringResource(R.string.install_type)) },
supportingContent = { Text(stringResource(viewModel.installedApp.installType.stringResource)) }
)
}
}
}
}

View file

@ -12,16 +12,7 @@ import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -35,6 +26,7 @@ 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.data.room.apps.installed.InstalledApp
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.bundle.ImportBundleDialog
import app.revanced.manager.ui.viewmodel.DashboardViewModel
@ -56,6 +48,7 @@ fun DashboardScreen(
vm: DashboardViewModel = getViewModel(),
onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit,
onAppClick: (InstalledApp) -> Unit
) {
var showImportBundleDialog by rememberSaveable { mutableStateOf(false) }
val pages: Array<DashboardPage> = DashboardPage.values()
@ -150,7 +143,9 @@ fun DashboardScreen(
pageContent = { index ->
when (pages[index]) {
DashboardPage.DASHBOARD -> {
InstalledAppsScreen()
InstalledAppsScreen(
onAppClick = onAppClick
)
}
DashboardPage.BUNDLES -> {

View file

@ -1,21 +1,73 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
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.data.room.apps.installed.InstalledApp
import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel
import org.koin.androidx.compose.getViewModel
@Composable
fun InstalledAppsScreen() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = stringResource(R.string.no_patched_apps_found),
style = MaterialTheme.typography.titleLarge
)
fun InstalledAppsScreen(
onAppClick: (InstalledApp) -> Unit,
viewModel: InstalledAppsViewModel = getViewModel()
) {
val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null)
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = installedApps?.let { if (it.isEmpty()) Arrangement.Center else Arrangement.Top } ?: Arrangement.Center
) {
installedApps?.let { installedApps ->
if (installedApps.isNotEmpty()) {
items(
installedApps,
key = { it.currentPackageName }
) { installedApp ->
viewModel.packageInfoMap[installedApp.currentPackageName].let { packageInfo ->
ListItem(
modifier = Modifier.clickable { onAppClick(installedApp) },
leadingContent = {
AppIcon(
packageInfo,
contentDescription = null,
Modifier.size(36.dp)
)
},
headlineContent = { AppLabel(packageInfo, defaultText = null) },
supportingContent = { Text(installedApp.currentPackageName) }
)
}
}
} else {
item {
Text(
text = stringResource(R.string.no_patched_apps_found),
style = MaterialTheme.typography.titleLarge
)
}
}
} ?: item { LoadingIndicator() }
}
}

View file

@ -70,7 +70,7 @@ fun PatchesSelectorScreen(
if (vm.compatibleVersions.isNotEmpty())
UnsupportedDialog(
appVersion = vm.selectedApp.version,
appVersion = vm.input.selectedApp.version,
supportedVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs
)

View file

@ -27,6 +27,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.draw.alpha
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -89,7 +90,7 @@ fun VersionSelectorScreen(
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
viewModel.installedApp?.let { packageInfo ->
viewModel.installedApp?.let { (packageInfo, alreadyPatched) ->
SelectedApp.Installed(
packageName = viewModel.packageName,
version = packageInfo.versionName
@ -98,7 +99,8 @@ fun VersionSelectorScreen(
selectedApp = it,
selected = selectedVersion == it,
onClick = { selectedVersion = it },
patchCount = supportedVersions[it.version]
patchCount = supportedVersions[it.version],
alreadyPatched = alreadyPatched
)
}
}
@ -132,14 +134,13 @@ fun VersionSelectorScreen(
}
}
const val alreadyPatched = false
@Composable
fun SelectedAppItem(
selectedApp: SelectedApp,
selected: Boolean,
onClick: () -> Unit,
patchCount: Int?
patchCount: Int?,
alreadyPatched: Boolean = false
) {
ListItem(
leadingContent = { RadioButton(selected, null) },
@ -161,6 +162,11 @@ fun SelectedAppItem(
trailingContent = patchCount?.let { {
Text(pluralStringResource(R.plurals.patches_count, it, it))
} },
modifier = Modifier.clickable(onClick = onClick)
modifier = Modifier
.clickable(enabled = !alreadyPatched, onClick = onClick)
.run {
if (alreadyPatched) alpha(0.5f)
else this
}
)
}

View file

@ -0,0 +1,96 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
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 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.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.service.UninstallService
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class AppInfoViewModel(
val installedApp: InstalledApp
) : ViewModel(), KoinComponent {
private val app: Application by inject()
private val pm: PM by inject()
private val installedAppRepository: InstalledAppRepository by inject()
lateinit var onBackClick: () -> Unit
var appInfo: PackageInfo? by mutableStateOf(null)
private set
var appliedPatches: PatchesSelection? by mutableStateOf(null)
fun launch() = pm.launch(installedApp.currentPackageName)
fun uninstall() {
when (installedApp.installType) {
InstallType.DEFAULT -> pm.uninstallPackage(installedApp.currentPackageName)
InstallType.ROOT -> TODO()
}
}
private val uninstallBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
UninstallService.APP_UNINSTALL_ACTION -> {
val extraStatus = intent.getIntExtra(UninstallService.EXTRA_UNINSTALL_STATUS, -999)
val extraStatusMessage = intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE)
if (extraStatus == PackageInstaller.STATUS_SUCCESS) {
viewModelScope.launch {
installedAppRepository.delete(installedApp)
withContext(Dispatchers.Main) { onBackClick() }
}
} else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) {
app.toast(app.getString(R.string.uninstall_app_fail, extraStatusMessage))
}
}
}
}
}
init {
viewModelScope.launch {
appInfo = withContext(Dispatchers.IO) {
pm.getPackageInfo(installedApp.currentPackageName)
}
}
viewModelScope.launch {
appliedPatches = withContext(Dispatchers.IO) {
installedAppRepository.getAppliedPatches(installedApp.currentPackageName)
}
}
app.registerReceiver(
uninstallBroadcastReceiver,
IntentFilter(UninstallService.APP_UNINSTALL_ACTION)
)
}
override fun onCleared() {
super.onCleared()
app.unregisterReceiver(uninstallBroadcastReceiver)
}
}

View file

@ -2,7 +2,7 @@ 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.data.room.apps.downloaded.DownloadedApp
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.util.mutableStateSetOf

View file

@ -0,0 +1,33 @@
package app.revanced.manager.ui.viewmodel
import android.content.pm.PackageInfo
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.util.PM
import app.revanced.manager.util.collectEach
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class InstalledAppsViewModel(
private val installedAppsRepository: InstalledAppRepository,
private val pm: PM
) : ViewModel() {
val apps = installedAppsRepository.getAll().flowOn(Dispatchers.IO)
val packageInfoMap = mutableStateMapOf<String, PackageInfo?>()
init {
viewModelScope.launch {
apps.collectEach { installedApp ->
packageInfoMap[installedApp.currentPackageName] = withContext(Dispatchers.IO) {
pm.getPackageInfo(installedApp.currentPackageName)
.also { if (it == null) installedAppsRepository.delete(installedApp) }
}
}
}
}
}

View file

@ -18,8 +18,10 @@ import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.worker.PatcherProgressManager
import app.revanced.manager.patcher.worker.PatcherWorker
@ -50,6 +52,7 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
private val app: Application by inject()
private val pm: PM by inject()
private val workerRepository: WorkerRepository by inject()
private val installedAppReceiver: InstalledAppRepository by inject()
val packageName: String = input.selectedApp.packageName
private val outputFile = File(app.cacheDir, "output.apk")
@ -113,6 +116,15 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
app.toast(app.getString(R.string.install_app_success))
installedPackageName =
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME)
viewModelScope.launch {
installedAppReceiver.add(
installedPackageName!!,
packageName,
input.selectedApp.version,
InstallType.DEFAULT,
input.selectedPatches
)
}
} else {
app.toast(app.getString(R.string.install_app_fail, extra))
}

View file

@ -17,7 +17,7 @@ import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.SnapshotStateSet
@ -37,7 +37,7 @@ import org.koin.core.component.get
@Stable
@OptIn(SavedStateHandleSaveableApi::class)
class PatchesSelectorViewModel(
val selectedApp: SelectedApp
val input: Destination.PatchesSelector
) : ViewModel(), KoinComponent {
private val selectionRepository: PatchSelectionRepository = get()
private val savedStateHandle: SavedStateHandle = get()
@ -54,10 +54,10 @@ class PatchesSelectorViewModel(
val unsupported = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
bundle.patches.filter { it.compatibleWith(selectedApp.packageName) }.forEach {
bundle.patches.filter { it.compatibleWith(input.selectedApp.packageName) }.forEach {
val targetList = when {
it.compatiblePackages == null -> universal
it.supportsVersion(selectedApp.version) -> supported
it.supportsVersion(input.selectedApp.version) -> supported
else -> unsupported
}
@ -75,7 +75,8 @@ class PatchesSelectorViewModel(
viewModelScope.launch(Dispatchers.Default) {
val bundles = bundlesFlow.first()
val filteredSelection =
selectionRepository.getSelection(selectedApp.packageName)
(input.patchesSelection
?: selectionRepository.getSelection(input.selectedApp.packageName))
.mapValues { (uid, patches) ->
// Filter out patches that don't exist.
val filteredPatches = bundles.singleOrNull { it.uid == uid }
@ -125,7 +126,7 @@ class PatchesSelectorViewModel(
suspend fun getAndSaveSelection(): PatchesSelection =
selectedPatches.also {
withContext(Dispatchers.Default) {
selectionRepository.updateSelection(selectedApp.packageName, it)
selectionRepository.updateSelection(input.selectedApp.packageName, it)
}
}.mapValues { it.value.toMutableSet() }.apply {
if (allowExperimental.get()) {
@ -158,7 +159,7 @@ class PatchesSelectorViewModel(
val set = HashSet<String>()
unsupportedVersions.forEach { patch ->
patch.compatiblePackages?.find { it.packageName == selectedApp.packageName }
patch.compatiblePackages?.find { it.packageName == input.selectedApp.packageName }
?.let { compatiblePackage ->
set.addAll(compatiblePackage.versions)
}

View file

@ -7,7 +7,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.network.downloader.APKMirror
import app.revanced.manager.network.downloader.AppDownloader
@ -17,6 +19,7 @@ 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.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ -28,11 +31,12 @@ class VersionSelectorViewModel(
val packageName: String
) : ViewModel(), KoinComponent {
private val downloadedAppRepository: DownloadedAppRepository by inject()
private val installedAppRepository: InstalledAppRepository by inject()
private val patchBundleRepository: PatchBundleRepository by inject()
private val pm: PM by inject()
private val appDownloader: AppDownloader = APKMirror()
var installedApp: PackageInfo? by mutableStateOf(null)
var installedApp: Pair<PackageInfo, Boolean>? by mutableStateOf(null)
private set
var isLoading by mutableStateOf(true)
private set
@ -67,7 +71,17 @@ class VersionSelectorViewModel(
init {
viewModelScope.launch(Dispatchers.Main) {
installedApp = withContext(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val alreadyPatched = async(Dispatchers.IO) {
installedAppRepository.get(packageName)
?.let { it.installType == InstallType.DEFAULT }
?: false
}
installedApp =
packageInfo.await()?.let {
it to alreadyPatched.await()
}
}
viewModelScope.launch(Dispatchers.IO) {

View file

@ -53,21 +53,18 @@ class PM(
.eachCount()
compatiblePackages.keys.map { pkg ->
try {
val packageInfo = app.packageManager.getPackageInfo(pkg, 0)
getPackageInfo(pkg)?.let { packageInfo ->
AppInfo(
pkg,
compatiblePackages[pkg],
packageInfo,
File(packageInfo.applicationInfo.sourceDir)
)
} catch (e: NameNotFoundException) {
AppInfo(
pkg,
compatiblePackages[pkg],
null
)
}
} ?: AppInfo(
pkg,
compatiblePackages[pkg],
null
)
}
}

View file

@ -89,4 +89,12 @@ val Color.hexCode: String
val g: Int = (green * 255).toInt()
val b: Int = (blue * 255).toInt()
return java.lang.String.format(Locale.getDefault(), "%02X%02X%02X%02X", r, g, b, a)
}
}
suspend fun <T> Flow<Iterable<T>>.collectEach(block: suspend (T) -> Unit) {
this.collect { iterable ->
iterable.forEach {
block(it)
}
}
}

View file

@ -4,4 +4,8 @@
<item quantity="one">%d Patch</item>
<item quantity="other">%d Patches</item>
</plurals>
<plurals name="applied_patches">
<item quantity="one">%d applied patch</item>
<item quantity="other">%d applied patches</item>
</plurals>
</resources>

View file

@ -154,6 +154,17 @@
<string name="loading">Loading…</string>
<string name="not_installed">Not installed</string>
<string name="app_info">App info</string>
<string name="uninstall">Uninstall</string>
<string name="repatch">Repatch</string>
<string name="install_type">Installation type</string>
<string name="package_name">Package name</string>
<string name="original_package_name">Original package name</string>
<string name="applied_patches">Applied patches</string>
<string name="view_applied_patches">View applied patches</string>
<string name="default_install">Default</string>
<string name="root_install">Root</string>
<string name="error_occurred">An error occurred</string>
<string name="already_downloaded">Already downloaded</string>
@ -173,6 +184,7 @@
<string name="install_app">Install</string>
<string name="install_app_success">App installed</string>
<string name="install_app_fail">Failed to install app: %s</string>
<string name="uninstall_app_fail">Failed to uninstall app: %s</string>
<string name="open_app">Open</string>
<string name="export_app">Export</string>
<string name="export_app_success">Apk exported</string>