diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d7b4fbc..764ea2b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -73,6 +73,7 @@ dependencies {
implementation("com.takisoft.preferencex:preferencex:1.1.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.lifecycle:lifecycle-service:2.8.4")
testImplementation("junit:junit:4.13.2")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3dd4840..71e714b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,9 @@
+
+
,
+ private val onChange: (Model, Boolean) -> Unit,
+) : RecyclerView.Adapter() {
+
+ private val comparator = compareBy({ !checker.test(it) }, { it?.label })
+
+ private val list: SortedList = SortedList(Model::class.java, SortedList.BatchedCallback(object :
+ SortedList.Callback() {
+
+ override fun compare(o1: Model?, o2: Model?): Int {
+ return comparator.compare(o1, o2)
+ }
+
+ override fun onInserted(position: Int, count: Int) {
+ notifyItemRangeInserted(position, count)
+ }
+
+ override fun onRemoved(position: Int, count: Int) {
+ notifyItemRangeRemoved(position, count)
+ }
+
+ override fun onMoved(fromPosition: Int, toPosition: Int) {
+ notifyItemMoved(fromPosition, toPosition)
+ }
+
+ override fun onChanged(position: Int, count: Int) {
+ notifyItemRangeChanged(position, count)
+ }
+
+ override fun areItemsTheSame(item1: Model?, item2: Model?): Boolean =
+ item1?.packageName == item2?.packageName
+
+ override fun areContentsTheSame(oldItem: Model?, newItem: Model?): Boolean =
+ oldItem == newItem
+
+ }))
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): ViewHolder = ViewHolder(ItemAppBinding.inflate(LayoutInflater.from(context), parent, false))
+
+ override fun getItemCount(): Int {
+ return list.size()
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val model = list[position]
+
+ holder.binding.apply {
+ appLabel.text = model.label
+ appPackage.text = model.packageName
+ icon.setImageDrawable(model.icon)
+
+ checkbox.isChecked = checker.test(model)
+
+ root.setOnClickListener {
+ val checked = !checkbox.isChecked
+ checkbox.isChecked = checked
+ onChange(model, checked)
+ }
+ }
+ }
+
+ fun setList(newList: List) {
+ list.beginBatchedUpdates()
+ list.clear()
+ list.addAll(newList)
+ list.endBatchedUpdates()
+ }
+
+ class ViewHolder(
+ val binding: ItemAppBinding
+ ) : RecyclerView.ViewHolder(binding.root)
+
+ data class Model(
+ val label: String,
+ val packageName: String,
+ val icon: Drawable
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/fragments/VpnAppsFilterFragment.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/fragments/VpnAppsFilterFragment.kt
new file mode 100644
index 0000000..282c307
--- /dev/null
+++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/fragments/VpnAppsFilterFragment.kt
@@ -0,0 +1,131 @@
+package io.github.dovecoteescapee.byedpi.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.widget.SearchView
+import androidx.appcompat.widget.SearchView.OnQueryTextListener
+import androidx.core.content.edit
+import androidx.core.view.MenuProvider
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Lifecycle
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import io.github.dovecoteescapee.byedpi.adapters.AppItemAdapter
+import io.github.dovecoteescapee.byedpi.R
+import io.github.dovecoteescapee.byedpi.databinding.FragmentFilterBinding
+import io.github.dovecoteescapee.byedpi.utility.getPreferences
+
+class VpnAppsFilterFragment : Fragment(), MenuProvider {
+
+ private lateinit var binding: FragmentFilterBinding
+ private lateinit var adapter: AppItemAdapter
+
+ private lateinit var apps: List
+ private lateinit var checked: MutableSet
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = FragmentFilterBinding.inflate(inflater, container, false)
+
+ val activity = requireActivity()
+
+ checked = activity.getPreferences().getStringSet("vpn_filtered_apps", emptySet())!!
+ .toMutableSet()
+ adapter = AppItemAdapter(
+ activity,
+ { checked.contains(it.packageName) },
+ { app, status ->
+ checked.apply {
+ if (status) {
+ add(app.packageName)
+ } else {
+ remove(app.packageName)
+ }
+ }
+ activity.getPreferences().edit {
+ putStringSet("vpn_filtered_apps", checked.toSet())
+ }
+ })
+
+
+ binding.appList.layoutManager = LinearLayoutManager(activity)
+ binding.appList.addItemDecoration(
+ DividerItemDecoration(
+ binding.appList.context,
+ RecyclerView.VERTICAL
+ )
+ )
+
+ binding.appList.adapter = adapter
+
+ Thread {
+ apps = collectApps()
+
+ activity.runOnUiThread {
+ adapter.setList(apps)
+
+ binding.progressCircular.visibility = View.GONE
+ binding.appList.visibility = View.VISIBLE
+ }
+ }.start()
+
+ requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED)
+
+ return binding.root
+ }
+
+ private fun collectApps(): List {
+ val context = requireContext()
+ val packageManager = context.packageManager
+
+ return packageManager.getInstalledApplications(0)
+ .filter { it.packageName != context.packageName }
+ .map {
+ AppItemAdapter.Model(
+ label = packageManager.getApplicationLabel(it).toString(),
+ packageName = it.packageName,
+ icon = packageManager.getApplicationIcon(it),
+ )
+ }
+ }
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.menu_filter, menu)
+
+ val searchItem = menu.findItem(R.id.action_search)
+ (searchItem.actionView as SearchView).setOnQueryTextListener(object : OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String): Boolean {
+ val lQuery = query.lowercase()
+ adapter.setList(apps.filter { it.label.lowercase().contains(lQuery) })
+ return false
+ }
+
+ override fun onQueryTextChange(query: String?): Boolean {
+ return false
+ }
+ })
+ searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
+ override fun onMenuItemActionExpand(item: MenuItem): Boolean {
+ return true
+ }
+
+ override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
+ adapter.setList(apps)
+ return true
+ }
+ })
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return false
+ }
+}
diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiVpnService.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiVpnService.kt
index d2abdfd..bf62626 100644
--- a/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiVpnService.kt
+++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiVpnService.kt
@@ -3,6 +3,7 @@ package io.github.dovecoteescapee.byedpi.services
import android.app.Notification
import android.app.PendingIntent
import android.content.Intent
+import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.ParcelFileDescriptor
@@ -297,7 +298,27 @@ class ByeDpiVpnService : LifecycleVpnService() {
builder.setMetered(false)
}
- builder.addDisallowedApplication(applicationContext.packageName)
+ val filter = getPreferences().getStringSet("vpn_filtered_apps", emptySet())!!
+ when (val filterMode = getPreferences().getString("vpn_filter_mode", "blacklist")) {
+ "blacklist" -> {
+ filter.forEach {
+ try {
+ builder.addDisallowedApplication(it)
+ } catch (ignore: PackageManager.NameNotFoundException) {}
+ }
+ builder.addDisallowedApplication(applicationContext.packageName)
+ }
+ "whitelist" -> {
+ filter.forEach {
+ try {
+ builder.addAllowedApplication(it)
+ } catch (ignore: PackageManager.NameNotFoundException) {}
+ }
+ }
+ else -> {
+ Log.w(TAG, "Invalid VPN filter mode: $filterMode")
+ }
+ }
return builder
}
diff --git a/app/src/main/res/drawable-anydpi-v24/ic_search.xml b/app/src/main/res/drawable-anydpi-v24/ic_search.xml
new file mode 100644
index 0000000..79129dd
--- /dev/null
+++ b/app/src/main/res/drawable-anydpi-v24/ic_search.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable-hdpi/ic_search.png b/app/src/main/res/drawable-hdpi/ic_search.png
new file mode 100644
index 0000000..a2ec243
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_search.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_search.png b/app/src/main/res/drawable-mdpi/ic_search.png
new file mode 100644
index 0000000..c650250
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_search.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_search.png b/app/src/main/res/drawable-xhdpi/ic_search.png
new file mode 100644
index 0000000..9e7ce1e
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_search.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_search.png b/app/src/main/res/drawable-xxhdpi/ic_search.png
new file mode 100644
index 0000000..64e56b9
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_search.png differ
diff --git a/app/src/main/res/layout/fragment_filter.xml b/app/src/main/res/layout/fragment_filter.xml
new file mode 100644
index 0000000..788271d
--- /dev/null
+++ b/app/src/main/res/layout/fragment_filter.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_app.xml b/app/src/main/res/layout/item_app.xml
new file mode 100644
index 0000000..ec1b05e
--- /dev/null
+++ b/app/src/main/res/layout/item_app.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_filter.xml b/app/src/main/res/menu/menu_filter.xml
new file mode 100644
index 0000000..e89c3fb
--- /dev/null
+++ b/app/src/main/res/menu/menu_filter.xml
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index 0b1744c..69f0bc8 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -47,4 +47,13 @@
- blacklist
- whitelist
+
+
+ - Blacklist apps
+ - Whilelist apps
+
+
+ - blacklist
+ - whitelist
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index dc26369..ad9ac8b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -72,4 +72,8 @@
HTTPS
Protocols
Uncheck all to desync all traffic
+ VPN
+ Apps filtering mode
+ Filtered applications
+ Search
diff --git a/app/src/main/res/xml/main_settings.xml b/app/src/main/res/xml/main_settings.xml
index e81fc6b..d08cd05 100644
--- a/app/src/main/res/xml/main_settings.xml
+++ b/app/src/main/res/xml/main_settings.xml
@@ -58,6 +58,25 @@
+
+
+
+
+
+
+
+