/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai
*
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see . *
* *
******************************************************************************/
package io.nekohasekai.sagernet.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.util.SparseBooleanArray
import android.view.*
import android.widget.Filter
import android.widget.Filterable
import androidx.activity.OnBackPressedCallback
import androidx.annotation.UiThread
import androidx.appcompat.widget.SearchView
import androidx.core.util.contains
import androidx.core.util.set
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
import io.nekohasekai.sagernet.BuildConfig
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.SagerNet
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.databinding.LayoutAppsBinding
import io.nekohasekai.sagernet.databinding.LayoutAppsItemBinding
import io.nekohasekai.sagernet.ktx.crossFadeFrom
import io.nekohasekai.sagernet.ktx.dp2px
import io.nekohasekai.sagernet.ktx.onMainDispatcher
import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
import io.nekohasekai.sagernet.utils.PackageCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.coroutineContext
class AppManagerActivity : ThemedActivity() {
override val onBackPressedCallback = object : OnBackPressedCallback(enabled = false) {
override fun handleOnBackPressed() {
searchView.onActionViewCollapsed()
searchView.clearFocus()
}
}
companion object {
@SuppressLint("StaticFieldLeak")
private var instance: AppManagerActivity? = null
private const val SWITCH = "switch"
private val cachedApps
get() = PackageCache.installedPackages.toMutableMap().apply {
remove(BuildConfig.APPLICATION_ID)
}
}
private class ProxiedApp(
private val pm: PackageManager, private val appInfo: ApplicationInfo,
val packageName: String,
) {
val name: CharSequence = appInfo.loadLabel(pm) // cached for sorting
val icon: Drawable get() = appInfo.loadIcon(pm)
val uid get() = appInfo.uid
}
private inner class AppViewHolder(val binding: LayoutAppsItemBinding) : RecyclerView.ViewHolder(
binding.root
),
View.OnClickListener {
private lateinit var item: ProxiedApp
init {
binding.root.setOnClickListener(this)
}
fun bind(app: ProxiedApp) {
item = app
binding.itemicon.setImageDrawable(app.icon)
binding.title.text = app.name
binding.desc.text = "${app.packageName} (${app.uid})"
binding.itemcheck.isChecked = isProxiedApp(app)
}
fun handlePayload(payloads: List) {
if (payloads.contains(SWITCH)) binding.itemcheck.isChecked = isProxiedApp(item)
}
override fun onClick(v: View?) {
if (isProxiedApp(item)) proxiedUids.delete(item.uid) else proxiedUids[item.uid] = true
DataStore.individual = apps.filter { isProxiedApp(it) }
.joinToString("\n") { it.packageName }
appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH)
}
}
private inner class AppsAdapter : RecyclerView.Adapter(),
Filterable,
FastScrollRecyclerView.SectionedAdapter {
var filteredApps = apps
suspend fun reload() {
apps = cachedApps.map { (packageName, packageInfo) ->
coroutineContext[Job]!!.ensureActive()
ProxiedApp(packageManager, packageInfo.applicationInfo!!, packageName)
}.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() }))
}
override fun onBindViewHolder(holder: AppViewHolder, position: Int) =
holder.bind(filteredApps[position])
override fun onBindViewHolder(holder: AppViewHolder, position: Int, payloads: List) {
if (payloads.isNotEmpty()) {
@Suppress("UNCHECKED_CAST") holder.handlePayload(payloads as List)
return
}
onBindViewHolder(holder, position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder =
AppViewHolder(LayoutAppsItemBinding.inflate(layoutInflater, parent, false))
override fun getItemCount(): Int = filteredApps.size
private val filterImpl = object : Filter() {
override fun performFiltering(constraint: CharSequence) = FilterResults().apply {
var filteredApps = if (constraint.isEmpty()) apps else apps.filter {
it.name.contains(constraint, true) || it.packageName.contains(
constraint, true
) || it.uid.toString().contains(constraint)
}
count = filteredApps.size
values = filteredApps
}
override fun publishResults(constraint: CharSequence, results: FilterResults) {
@Suppress("UNCHECKED_CAST")
filteredApps = results.values as List
notifyDataSetChanged()
}
}
override fun getFilter(): Filter = filterImpl
override fun getSectionName(position: Int): String {
return filteredApps[position].name.firstOrNull()?.toString() ?: ""
}
}
private val loading by lazy { findViewById(R.id.loading) }
private lateinit var binding: LayoutAppsBinding
private val proxiedUids = SparseBooleanArray()
private var loader: Job? = null
private var apps = emptyList()
private val appsAdapter = AppsAdapter()
private lateinit var searchView: SearchView
private fun initProxiedUids(str: String = DataStore.individual) {
proxiedUids.clear()
PackageCache.awaitLoadSync()
val apps = cachedApps
for (line in str.lineSequence()) proxiedUids[(apps[line]
?: continue).applicationInfo!!.uid] = true
}
private fun isProxiedApp(app: ProxiedApp) = proxiedUids[app.uid]
@UiThread
private fun loadApps() {
loader?.cancel()
loader = lifecycleScope.launch {
loading.crossFadeFrom(binding.list)
val adapter = binding.list.adapter as AppsAdapter
withContext(Dispatchers.IO) { adapter.reload() }
adapter.filter.filter( "")
binding.list.crossFadeFrom(loading)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutAppsBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.bypassGroup)) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(
left = bars.left + dp2px(4),
right = bars.right + dp2px(4),
)
insets
}
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.list)) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(
left = bars.left,
right = bars.right,
bottom = bars.bottom,
)
insets
}
setSupportActionBar(findViewById(R.id.toolbar))
supportActionBar?.apply {
setTitle(R.string.proxied_apps)
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(R.drawable.baseline_arrow_back_24)
}
if (!DataStore.proxyApps) {
DataStore.proxyApps = true
}
binding.bypassGroup.check(if (DataStore.bypass) R.id.appProxyModeBypass else R.id.appProxyModeOn)
binding.bypassGroup.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.appProxyModeDisable -> {
DataStore.proxyApps = false
finish()
}
R.id.appProxyModeOn -> DataStore.bypass = false
R.id.appProxyModeBypass -> DataStore.bypass = true
}
}
initProxiedUids()
binding.list.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false)
binding.list.itemAnimator = DefaultItemAnimator()
binding.list.adapter = appsAdapter
instance = this
loadApps()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.per_app_proxy_menu, menu)
searchView = menu.findItem(R.id.action_search).actionView as SearchView
searchView.setOnQueryTextFocusChangeListener { _, hasFocus ->
onBackPressedCallback.isEnabled = hasFocus
}
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?) = false
override fun onQueryTextChange(newText: String?) = true.also { appsAdapter.filter.filter(newText) }
})
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_invert_selections -> {
runOnDefaultDispatcher {
for (app in apps) {
if (proxiedUids.contains(app.uid)) {
proxiedUids.delete(app.uid)
} else {
proxiedUids[app.uid] = true
}
}
DataStore.individual = apps.filter { isProxiedApp(it) }
.joinToString("\n") { it.packageName }
apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() }))
onMainDispatcher {
appsAdapter.filter.filter("")
}
}
return true
}
R.id.action_clear_selections -> {
runOnDefaultDispatcher {
proxiedUids.clear()
DataStore.individual = ""
apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() }))
onMainDispatcher {
appsAdapter.filter.filter("")
}
}
}
R.id.action_export_clipboard -> {
val success = SagerNet.trySetPrimaryClip("${DataStore.bypass}\n${DataStore.individual}")
Snackbar.make(
binding.list,
if (success) R.string.action_export_msg else R.string.action_export_err,
Snackbar.LENGTH_LONG
).show()
return true
}
R.id.action_import_clipboard -> {
val proxiedAppString = SagerNet.clipboard.primaryClip?.getItemAt(0)?.text?.toString()
if (!proxiedAppString.isNullOrEmpty()) {
val i = proxiedAppString.indexOf('\n')
try {
val (enabled, apps) = if (i < 0) {
proxiedAppString to ""
} else proxiedAppString.substring(
0, i
) to proxiedAppString.substring(i + 1)
binding.bypassGroup.check(if (enabled.toBoolean()) R.id.appProxyModeBypass else R.id.appProxyModeOn)
DataStore.individual = apps
Snackbar.make(
binding.list, R.string.action_import_msg, Snackbar.LENGTH_LONG
).show()
initProxiedUids(apps)
appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH)
return true
} catch (_: IllegalArgumentException) {
}
}
Snackbar.make(binding.list, R.string.action_import_err, Snackbar.LENGTH_LONG).show()
}
}
return super.onOptionsItemSelected(item)
}
override fun supportNavigateUpTo(upIntent: Intent) =
super.supportNavigateUpTo(upIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP))
override fun onDestroy() {
instance = null
loader?.cancel()
super.onDestroy()
}
}