mirror of
https://github.com/revanced/revanced-manager-compose
synced 2024-09-10 08:27:11 +02:00
feat: app selector screen
This commit is contained in:
parent
d3dbe33262
commit
01847c901c
@ -46,29 +46,29 @@ dependencies {
|
|||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
|
||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
implementation("androidx.activity:activity-compose:1.7.1")
|
implementation("androidx.activity:activity-compose:1.7.1")
|
||||||
|
implementation("androidx.paging:paging-common-ktx:3.1.1")
|
||||||
|
|
||||||
// Compose
|
// Compose
|
||||||
implementation(platform("androidx.compose:compose-bom:2023.04.01"))
|
implementation(platform("androidx.compose:compose-bom:2023.05.00"))
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.paging:paging-common-ktx:3.1.1")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
implementation("androidx.core:core-ktx:1.10.0")
|
implementation("androidx.compose.material3:material3:1.1.0-rc01")
|
||||||
|
|
||||||
// Accompanist
|
// Accompanist
|
||||||
val accompanistVersion = "0.30.1"
|
//val accompanistVersion = "0.30.1"
|
||||||
implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion")
|
//implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion")
|
||||||
//implementation("com.google.accompanist:accompanist-placeholder-material:$accompanistVersion")
|
//implementation("com.google.accompanist:accompanist-placeholder-material:$accompanistVersion")
|
||||||
implementation("com.google.accompanist:accompanist-drawablepainter:$accompanistVersion")
|
//implementation("com.google.accompanist:accompanist-drawablepainter:$accompanistVersion")
|
||||||
//implementation("com.google.accompanist:accompanist-flowlayout:$accompanistVersion")
|
//implementation("com.google.accompanist:accompanist-flowlayout:$accompanistVersion")
|
||||||
//implementation("com.google.accompanist:accompanist-permissions:$accompanistVersion")
|
//implementation("com.google.accompanist:accompanist-permissions:$accompanistVersion")
|
||||||
|
|
||||||
|
// Coil (async image loading, network image)
|
||||||
|
implementation("io.coil-kt:coil-compose:2.2.2")
|
||||||
|
|
||||||
// KotlinX
|
// KotlinX
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
|
||||||
|
|
||||||
// Material 3
|
|
||||||
implementation("androidx.compose.material3:material3")
|
|
||||||
|
|
||||||
|
|
||||||
// ReVanced
|
// ReVanced
|
||||||
implementation("app.revanced:revanced-patcher:7.0.0")
|
implementation("app.revanced:revanced-patcher:7.0.0")
|
||||||
|
|
||||||
@ -86,4 +86,5 @@ dependencies {
|
|||||||
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
|
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
|
||||||
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
|
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
|
||||||
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
|
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
|
||||||
}
|
|
||||||
|
}
|
@ -38,7 +38,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service android:name=".installer.service.InstallService" />
|
<service android:name=".service.InstallService" />
|
||||||
<service android:name=".installer.service.UninstallService" />
|
<service android:name=".service.UninstallService" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
@ -6,24 +6,38 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import app.revanced.manager.compose.destination.Destination
|
|
||||||
import app.revanced.manager.compose.domain.manager.PreferencesManager
|
import app.revanced.manager.compose.domain.manager.PreferencesManager
|
||||||
|
import app.revanced.manager.compose.ui.destination.Destination
|
||||||
|
import app.revanced.manager.compose.ui.screen.AppSelectorScreen
|
||||||
import app.revanced.manager.compose.ui.screen.DashboardScreen
|
import app.revanced.manager.compose.ui.screen.DashboardScreen
|
||||||
import app.revanced.manager.compose.ui.theme.ReVancedManagerTheme
|
import app.revanced.manager.compose.ui.theme.ReVancedManagerTheme
|
||||||
import app.revanced.manager.compose.ui.theme.Theme
|
import app.revanced.manager.compose.ui.theme.Theme
|
||||||
|
import app.revanced.manager.compose.util.PM
|
||||||
import dev.olshevski.navigation.reimagined.AnimatedNavHost
|
import dev.olshevski.navigation.reimagined.AnimatedNavHost
|
||||||
import dev.olshevski.navigation.reimagined.NavBackHandler
|
import dev.olshevski.navigation.reimagined.NavBackHandler
|
||||||
|
import dev.olshevski.navigation.reimagined.navigate
|
||||||
|
import dev.olshevski.navigation.reimagined.pop
|
||||||
import dev.olshevski.navigation.reimagined.rememberNavController
|
import dev.olshevski.navigation.reimagined.rememberNavController
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val prefs: PreferencesManager by inject()
|
private val prefs: PreferencesManager by inject()
|
||||||
|
private val mainScope = MainScope()
|
||||||
|
|
||||||
@ExperimentalAnimationApi
|
@ExperimentalAnimationApi
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
|
|
||||||
|
val context = this
|
||||||
|
mainScope.launch(Dispatchers.IO) {
|
||||||
|
PM.loadApps(context)
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ReVancedManagerTheme(
|
ReVancedManagerTheme(
|
||||||
darkTheme = prefs.theme == Theme.SYSTEM && isSystemInDarkTheme() || prefs.theme == Theme.DARK,
|
darkTheme = prefs.theme == Theme.SYSTEM && isSystemInDarkTheme() || prefs.theme == Theme.DARK,
|
||||||
@ -34,12 +48,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
NavBackHandler(navController)
|
NavBackHandler(navController)
|
||||||
|
|
||||||
AnimatedNavHost(
|
AnimatedNavHost(
|
||||||
controller = navController,
|
controller = navController
|
||||||
) { destination ->
|
) { destination ->
|
||||||
when (destination) {
|
when (destination) {
|
||||||
is Destination.Dashboard -> {
|
|
||||||
DashboardScreen()
|
is Destination.Dashboard -> { DashboardScreen(
|
||||||
}
|
onAppSelectorClick = { navController.navigate(Destination.AppSelector) }
|
||||||
|
) }
|
||||||
|
|
||||||
|
is Destination.AppSelector -> AppSelectorScreen(
|
||||||
|
onBackClick = { navController.pop() }
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,68 +0,0 @@
|
|||||||
package app.revanced.manager.compose.installer.utils
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageInstaller
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import app.revanced.manager.compose.installer.service.InstallService
|
|
||||||
import app.revanced.manager.compose.installer.service.UninstallService
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readable
|
|
||||||
|
|
||||||
object PM {
|
|
||||||
|
|
||||||
fun installApp(apk: File, context: Context) {
|
|
||||||
val packageInstaller = context.packageManager.packageInstaller
|
|
||||||
val session =
|
|
||||||
packageInstaller.openSession(packageInstaller.createSession(sessionParams))
|
|
||||||
session.writeApk(apk)
|
|
||||||
session.commit(context.installIntentSender)
|
|
||||||
session.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uninstallPackage(pkg: String, context: Context) {
|
|
||||||
val packageInstaller = context.packageManager.packageInstaller
|
|
||||||
packageInstaller.uninstall(pkg, context.uninstallIntentSender)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PackageInstaller.Session.writeApk(apk: File) {
|
|
||||||
apk.inputStream().use { inputStream ->
|
|
||||||
openWrite(apk.name, 0, apk.length()).use { outputStream ->
|
|
||||||
inputStream.copyTo(outputStream, byteArraySize)
|
|
||||||
fsync(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val intentFlags
|
|
||||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
|
||||||
PendingIntent.FLAG_MUTABLE
|
|
||||||
else
|
|
||||||
0
|
|
||||||
|
|
||||||
private val sessionParams
|
|
||||||
get() = PackageInstaller.SessionParams(
|
|
||||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL
|
|
||||||
).apply {
|
|
||||||
setInstallReason(PackageManager.INSTALL_REASON_USER)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val Context.installIntentSender
|
|
||||||
get() = PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, InstallService::class.java),
|
|
||||||
intentFlags
|
|
||||||
).intentSender
|
|
||||||
|
|
||||||
private val Context.uninstallIntentSender
|
|
||||||
get() = PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, UninstallService::class.java),
|
|
||||||
intentFlags
|
|
||||||
).intentSender
|
|
@ -1,4 +1,4 @@
|
|||||||
package app.revanced.manager.compose.installer.service
|
package app.revanced.manager.compose.service
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
@ -1,4 +1,4 @@
|
|||||||
package app.revanced.manager.compose.installer.service
|
package app.revanced.manager.compose.service
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
@ -0,0 +1,41 @@
|
|||||||
|
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
|
||||||
|
import androidx.compose.material.icons.filled.Android
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppIcon(
|
||||||
|
drawable: Drawable?,
|
||||||
|
contentDescription: String?,
|
||||||
|
size: Int = 48
|
||||||
|
) {
|
||||||
|
if (drawable == null) {
|
||||||
|
val image = rememberVectorPainter(Icons.Default.Android)
|
||||||
|
val colorFilter = ColorFilter.tint(LocalContentColor.current)
|
||||||
|
|
||||||
|
Image(
|
||||||
|
image,
|
||||||
|
contentDescription,
|
||||||
|
Modifier.size(size.dp),
|
||||||
|
colorFilter = colorFilter
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val image = rememberAsyncImagePainter(drawable)
|
||||||
|
|
||||||
|
Image(
|
||||||
|
image,
|
||||||
|
contentDescription,
|
||||||
|
Modifier.size(size.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
package app.revanced.manager.compose.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppScaffold(
|
||||||
|
topBar: @Composable (TopAppBarScrollBehavior) -> Unit = {},
|
||||||
|
bottomBar: @Composable () -> Unit = {},
|
||||||
|
floatingActionButton: @Composable () -> Unit = {},
|
||||||
|
content: @Composable (PaddingValues) -> Unit
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = { topBar(scrollBehavior) },
|
||||||
|
bottomBar = bottomBar,
|
||||||
|
floatingActionButton = floatingActionButton,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppTopBar(
|
||||||
|
title: String,
|
||||||
|
onBackClick: (() -> Unit)? = null,
|
||||||
|
actions: @Composable (RowScope.() -> Unit) = {},
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||||
|
) {
|
||||||
|
val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||||
|
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
navigationIcon = {
|
||||||
|
if (onBackClick != null) {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = actions,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = containerColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
package app.revanced.manager.compose.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
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
|
||||||
|
import app.revanced.manager.compose.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingIndicator(progress: Float? = null, text: Int? = R.string.loading_body) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
if (text != null)
|
||||||
|
Text(stringResource(text))
|
||||||
|
if (progress == null) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp))
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(progress = progress, modifier = Modifier.padding(vertical = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package app.revanced.manager.compose.destination
|
package app.revanced.manager.compose.ui.destination
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
@ -8,4 +8,7 @@ sealed interface Destination: Parcelable {
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
object Dashboard: Destination
|
object Dashboard: Destination
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
object AppSelector: Destination
|
||||||
|
|
||||||
}
|
}
|
@ -0,0 +1,178 @@
|
|||||||
|
package app.revanced.manager.compose.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.outlined.HelpOutline
|
||||||
|
import androidx.compose.material.icons.outlined.Search
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.revanced.manager.compose.R
|
||||||
|
import app.revanced.manager.compose.ui.component.AppIcon
|
||||||
|
import app.revanced.manager.compose.ui.component.AppScaffold
|
||||||
|
import app.revanced.manager.compose.ui.component.AppTopBar
|
||||||
|
import app.revanced.manager.compose.ui.component.LoadingIndicator
|
||||||
|
import app.revanced.manager.compose.util.PM
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppSelectorScreen(
|
||||||
|
onBackClick: () -> Unit
|
||||||
|
) {
|
||||||
|
var filterText by rememberSaveable { mutableStateOf("") }
|
||||||
|
var search by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// TODO: find something better for this
|
||||||
|
if (search) {
|
||||||
|
SearchBar(
|
||||||
|
query = filterText,
|
||||||
|
onQueryChange = { filterText = it },
|
||||||
|
onSearch = { },
|
||||||
|
active = true,
|
||||||
|
onActiveChange = { search = it },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
placeholder = { Text(stringResource(R.string.search_apps)) },
|
||||||
|
leadingIcon = { IconButton({ search = false }) { Icon(Icons.Default.ArrowBack, null) } },
|
||||||
|
shape = SearchBarDefaults.inputFieldShape,
|
||||||
|
content = {
|
||||||
|
if (PM.appList.isNotEmpty()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
PM.appList
|
||||||
|
.filter { app ->
|
||||||
|
(app.label.contains(
|
||||||
|
filterText,
|
||||||
|
true
|
||||||
|
) or app.packageName.contains(filterText, true))
|
||||||
|
}
|
||||||
|
) { app ->
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { },
|
||||||
|
leadingContent = { AppIcon(app.icon, null, 36) },
|
||||||
|
headlineContent = { Text(app.label) },
|
||||||
|
supportingContent = { Text(app.packageName) },
|
||||||
|
trailingContent = { Text((PM.testList[app.packageName]?: 0).let { if (it == 1) "$it Patch" else "$it Patches" }) }
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LoadingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AppScaffold(
|
||||||
|
topBar = {
|
||||||
|
AppTopBar(
|
||||||
|
title = "Select an app",
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
actions = {
|
||||||
|
IconButton({}) {
|
||||||
|
Icon(Icons.Outlined.HelpOutline, "Help")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { search = true }) {
|
||||||
|
Icon(Icons.Outlined.Search, "Search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
if (PM.supportedAppList.isNotEmpty()) {
|
||||||
|
|
||||||
|
/*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) }
|
||||||
|
)
|
||||||
|
}*/
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { },
|
||||||
|
leadingContent = { Box(Modifier.size(36.dp), Alignment.Center) { Icon(Icons.Default.Storage, null, modifier = Modifier.size(24.dp)) } },
|
||||||
|
headlineContent = { Text("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 { },
|
||||||
|
leadingContent = { AppIcon(app.icon, null, 36) },
|
||||||
|
headlineContent = { Text(app.label) },
|
||||||
|
supportingContent = { Text(app.packageName) },
|
||||||
|
trailingContent = {
|
||||||
|
Text(
|
||||||
|
(PM.testList[app.packageName]?: 0).let { if (it == 1) "$it Patch" else "$it Patches" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PM.appList.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(Modifier.fillMaxWidth(), Alignment.Center) {
|
||||||
|
CircularProgressIndicator(Modifier.padding(vertical = 15.dp).size(24.dp), strokeWidth = 3.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LoadingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -15,16 +15,18 @@ import androidx.compose.material3.FloatingActionButton
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import app.revanced.manager.compose.R
|
import app.revanced.manager.compose.R
|
||||||
|
import app.revanced.manager.compose.ui.component.AppScaffold
|
||||||
|
import app.revanced.manager.compose.ui.component.AppTopBar
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
enum class DashboardPage(
|
enum class DashboardPage(
|
||||||
@ -37,16 +39,18 @@ enum class DashboardPage(
|
|||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen() {
|
fun DashboardScreen(
|
||||||
|
onAppSelectorClick: () -> Unit
|
||||||
|
) {
|
||||||
val pages: Array<DashboardPage> = DashboardPage.values()
|
val pages: Array<DashboardPage> = DashboardPage.values()
|
||||||
|
|
||||||
val pagerState = rememberPagerState()
|
val pagerState = rememberPagerState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
Scaffold(
|
AppScaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
AppTopBar(
|
||||||
title = { Text("ReVanced Manager") },
|
title = "ReVanced Manager",
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {}) {
|
IconButton(onClick = {}) {
|
||||||
Icon(imageVector = Icons.Outlined.Info, contentDescription = null)
|
Icon(imageVector = Icons.Outlined.Info, contentDescription = null)
|
||||||
@ -61,13 +65,20 @@ fun DashboardScreen() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(onClick = {}) {
|
FloatingActionButton(onClick = {
|
||||||
|
if (pagerState.currentPage == DashboardPage.DASHBOARD.ordinal)
|
||||||
|
onAppSelectorClick()
|
||||||
|
}
|
||||||
|
) {
|
||||||
Icon(imageVector = Icons.Default.Add, contentDescription = null)
|
Icon(imageVector = Icons.Default.Add, contentDescription = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(Modifier.padding(paddingValues)) {
|
Column(Modifier.padding(paddingValues)) {
|
||||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
TabRow(
|
||||||
|
selectedTabIndex = pagerState.currentPage,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||||
|
) {
|
||||||
pages.forEachIndexed { index, page ->
|
pages.forEachIndexed { index, page ->
|
||||||
val title = stringResource(id = page.titleResId)
|
val title = stringResource(id = page.titleResId)
|
||||||
Tab(
|
Tab(
|
||||||
|
@ -2,22 +2,20 @@ package app.revanced.manager.compose.ui.screen
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import app.revanced.manager.compose.R
|
import app.revanced.manager.compose.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InstalledAppsScreen() {
|
fun InstalledAppsScreen() {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.no_patched_apps_found),
|
text = stringResource(R.string.no_patched_apps_found),
|
||||||
fontSize = 24.sp,
|
style = MaterialTheme.typography.titleLarge
|
||||||
modifier = Modifier
|
|
||||||
.align(alignment = Alignment.Center)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,22 +2,20 @@ package app.revanced.manager.compose.ui.screen
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import app.revanced.manager.compose.R
|
import app.revanced.manager.compose.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SourcesScreen() {
|
fun SourcesScreen() {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.no_sources_set),
|
text = stringResource(R.string.no_sources_set),
|
||||||
fontSize = 24.sp,
|
style = MaterialTheme.typography.titleLarge
|
||||||
modifier = Modifier
|
|
||||||
.align(alignment = Alignment.Center)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,14 +2,14 @@ package app.revanced.manager.compose.ui.theme
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
primary = Purple80,
|
||||||
@ -25,8 +25,8 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReVancedManagerTheme(
|
fun ReVancedManagerTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean,
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
@ -37,11 +37,19 @@ fun ReVancedManagerTheme(
|
|||||||
darkTheme -> DarkColorScheme
|
darkTheme -> DarkColorScheme
|
||||||
else -> LightColorScheme
|
else -> LightColorScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
(view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb()
|
val activity = view.context as Activity
|
||||||
ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme
|
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(activity.window, false)
|
||||||
|
|
||||||
|
activity.window.navigationBarColor = colorScheme.background.toArgb()
|
||||||
|
activity.window.statusBarColor = Color.Transparent.toArgb()
|
||||||
|
|
||||||
|
WindowCompat.getInsetsController(activity.window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
|
WindowCompat.getInsetsController(activity.window, view).isAppearanceLightNavigationBars = !darkTheme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
161
app/src/main/java/app/revanced/manager/compose/util/PM.kt
Normal file
161
app/src/main/java/app/revanced/manager/compose/util/PM.kt
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
package app.revanced.manager.compose.util
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import app.revanced.manager.compose.service.InstallService
|
||||||
|
import app.revanced.manager.compose.service.UninstallService
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
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
|
||||||
|
|
||||||
|
@SuppressLint("QueryPermissionsNeeded")
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
object PM {
|
||||||
|
|
||||||
|
val testList = mapOf(
|
||||||
|
"com.google.android.youtube" to 59,
|
||||||
|
"com.android.vending" to 34,
|
||||||
|
"com.backdrops.wallpapers" to 2,
|
||||||
|
"com.termux" to 2,
|
||||||
|
"com.notinstalled.app" to 1,
|
||||||
|
"com.2notinstalled.app" to 1,
|
||||||
|
"org.adaway" to 5,
|
||||||
|
"com.activitymanager" to 1,
|
||||||
|
"com.guoshi.httpcanary" to 1,
|
||||||
|
"org.lsposed.lspatch" to 1,
|
||||||
|
"app.revanced.manager.flutter" to 100,
|
||||||
|
"com.reddit.frontpage" to 20
|
||||||
|
)
|
||||||
|
|
||||||
|
val appList = mutableStateListOf<AppInfo>()
|
||||||
|
val supportedAppList = mutableStateListOf<AppInfo>()
|
||||||
|
|
||||||
|
suspend fun loadApps(context: Context) {
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
|
||||||
|
testList.keys.map {
|
||||||
|
try {
|
||||||
|
val applicationInfo = packageManager.getApplicationInfo(it, 0)
|
||||||
|
|
||||||
|
AppInfo(
|
||||||
|
it,
|
||||||
|
applicationInfo.loadLabel(packageManager).toString(),
|
||||||
|
applicationInfo.loadIcon(packageManager),
|
||||||
|
)
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
AppInfo(
|
||||||
|
it,
|
||||||
|
"Not installed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.let { list ->
|
||||||
|
list.sortedWith(
|
||||||
|
compareByDescending<AppInfo> {
|
||||||
|
testList[it.packageName]
|
||||||
|
}.thenBy { it.label }.thenBy { it.packageName }
|
||||||
|
)
|
||||||
|
}.also {
|
||||||
|
withContext(Dispatchers.Main) { supportedAppList.addAll(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val localAppList = mutableListOf<AppInfo>()
|
||||||
|
|
||||||
|
packageManager.getInstalledApplications(PackageManager.GET_META_DATA).map {
|
||||||
|
AppInfo(
|
||||||
|
it.packageName,
|
||||||
|
it.loadLabel(packageManager).toString(),
|
||||||
|
it.loadIcon(packageManager)
|
||||||
|
)
|
||||||
|
}.also { localAppList.addAll(it) }
|
||||||
|
|
||||||
|
testList.keys.mapNotNull { packageName ->
|
||||||
|
if (!localAppList.any { packageName == it.packageName }) {
|
||||||
|
AppInfo(
|
||||||
|
packageName,
|
||||||
|
"Not installed"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.also { localAppList.addAll(it) }
|
||||||
|
|
||||||
|
localAppList.sortWith(
|
||||||
|
compareByDescending<AppInfo> {
|
||||||
|
testList[it.packageName]
|
||||||
|
}.thenBy { it.label }.thenBy { it.packageName }
|
||||||
|
).also {
|
||||||
|
withContext(Dispatchers.Main) { appList.addAll(localAppList) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class AppInfo(
|
||||||
|
val packageName: String,
|
||||||
|
val label: String,
|
||||||
|
val icon: @RawValue Drawable? = null
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
fun installApp(apk: File, context: Context) {
|
||||||
|
val packageInstaller = context.packageManager.packageInstaller
|
||||||
|
val session =
|
||||||
|
packageInstaller.openSession(packageInstaller.createSession(sessionParams))
|
||||||
|
session.writeApk(apk)
|
||||||
|
session.commit(context.installIntentSender)
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uninstallPackage(pkg: String, context: Context) {
|
||||||
|
val packageInstaller = context.packageManager.packageInstaller
|
||||||
|
packageInstaller.uninstall(pkg, context.uninstallIntentSender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PackageInstaller.Session.writeApk(apk: File) {
|
||||||
|
apk.inputStream().use { inputStream ->
|
||||||
|
openWrite(apk.name, 0, apk.length()).use { outputStream ->
|
||||||
|
inputStream.copyTo(outputStream, byteArraySize)
|
||||||
|
fsync(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val intentFlags
|
||||||
|
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
PendingIntent.FLAG_MUTABLE
|
||||||
|
else
|
||||||
|
0
|
||||||
|
|
||||||
|
private val sessionParams
|
||||||
|
get() = PackageInstaller.SessionParams(
|
||||||
|
PackageInstaller.SessionParams.MODE_FULL_INSTALL
|
||||||
|
).apply {
|
||||||
|
setInstallReason(PackageManager.INSTALL_REASON_USER)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Context.installIntentSender
|
||||||
|
get() = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, InstallService::class.java),
|
||||||
|
intentFlags
|
||||||
|
).intentSender
|
||||||
|
|
||||||
|
private val Context.uninstallIntentSender
|
||||||
|
get() = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, UninstallService::class.java),
|
||||||
|
intentFlags
|
||||||
|
).intentSender
|
@ -1,6 +1,26 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ReVanced Manager</string>
|
<string name="app_name">ReVanced Manager</string>
|
||||||
|
<string name="patcher">Patcher</string>
|
||||||
|
<string name="patches">Patches</string>
|
||||||
|
<string name="integrations">Integrations</string>
|
||||||
|
<string name="cli">CLI</string>
|
||||||
|
<string name="manager">Manager</string>
|
||||||
|
|
||||||
<string name="dashboard">Dashboard</string>
|
<string name="dashboard">Dashboard</string>
|
||||||
|
<string name="settings">Settings</string>
|
||||||
|
<string name="about">About</string>
|
||||||
|
|
||||||
|
<string name="search_apps">Search apps…</string>
|
||||||
|
<string name="loading_body">Loading…</string>
|
||||||
|
|
||||||
|
<string name="downloading_patches">Downloading patch bundle…</string>
|
||||||
|
<string name="downloading_integrations">Downloading Integrations…</string>
|
||||||
|
|
||||||
|
<string name="contributors">Contributors</string>
|
||||||
|
<string name="appearance">Appearance</string>
|
||||||
|
<string name="dynamic_color">Dynamic color</string>
|
||||||
|
<string name="theme">Theme</string>
|
||||||
|
<string name="storage">Storage</string>
|
||||||
<string name="tab_apps">Apps</string>
|
<string name="tab_apps">Apps</string>
|
||||||
<string name="tab_sources">Sources</string>
|
<string name="tab_sources">Sources</string>
|
||||||
<string name="no_sources_set">No sources set</string>
|
<string name="no_sources_set">No sources set</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user