owenclave / app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt

github - dev - back to tree - upstream
/****************************************************************************** * * * 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() } }