diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aeda923..2ba672a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,7 +37,6 @@ android:name=".ui.MainActivity" android:exported="true" android:icon="@mipmap/ic_launcher" - android:theme="@style/AppTheme.NoActionBar" android:launchMode="singleTask"> + diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt index 43c77a7..ac96d0c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -2,6 +2,7 @@ package io.nekohasekai.sfa import android.app.Application import android.app.NotificationManager +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -48,6 +49,7 @@ class Application : Application() { val powerManager by lazy { application.getSystemService()!! } val notificationManager by lazy { application.getSystemService()!! } val wifiManager by lazy { application.getSystemService()!! } + val clipboard by lazy { application.getSystemService()!! } } } \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt index db667b9..b788173 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.util.Log import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity +import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity0 class AppChangeReceiver : BroadcastReceiver() { @@ -37,7 +37,7 @@ class AppChangeReceiver : BroadcastReceiver() { Log.d(TAG, "missing package name in intent") return } - val isChinaApp = PerAppProxyActivity.scanChinaApps(listOf(packageName)).isNotEmpty() + val isChinaApp = PerAppProxyActivity0.scanChinaPackage(packageName) Log.d(TAG, "scan china app result for $packageName: $isChinaApp") if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE) xor !isChinaApp) { Settings.perAppProxyList += packageName diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt new file mode 100644 index 0000000..48ee0f4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt @@ -0,0 +1,12 @@ +package io.nekohasekai.sfa.ktx + +import android.content.ClipData +import io.nekohasekai.sfa.Application + +var clipboardText: String? + get() = Application.clipboard.primaryClip?.getItemAt(0)?.text?.toString() + set(plainText) { + if (plainText != null) { + Application.clipboard.setPrimaryClip(ClipData.newPlainText(null, plainText)) + } + } \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt index 6b91850..aea4807 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt @@ -1,12 +1,12 @@ package io.nekohasekai.sfa.ktx +import android.app.Activity import android.content.ActivityNotFoundException import androidx.activity.result.ActivityResultLauncher import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.ui.shared.AbstractActivity -fun AbstractActivity.startFilesForResult( +fun Activity.startFilesForResult( launcher: ActivityResultLauncher, input: String ) { try { diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt index db20646..37f3f95 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt @@ -53,7 +53,7 @@ import java.io.File import java.util.Date import java.util.LinkedList -class MainActivity : AbstractActivity(), +class MainActivity : AbstractActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, ServiceConnection.Callback { @@ -61,7 +61,6 @@ class MainActivity : AbstractActivity(), private const val TAG = "MainActivity" } - internal lateinit var binding: ActivityMainBinding private val connection = ServiceConnection(this, this) val logList = LinkedList() @@ -71,10 +70,6 @@ class MainActivity : AbstractActivity(), override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - setSupportActionBar(binding.toolbar) - val navController = findNavController(R.id.nav_host_fragment_activity_my) navController.setGraph(R.navigation.mobile_navigation) navController.navigate(R.id.navigation_dashboard) @@ -103,6 +98,7 @@ class MainActivity : AbstractActivity(), navDestination: NavDestination, bundle: Bundle? ) { + val binding = binding ?: return val destinationId = navDestination.id binding.dashboardTabContainer.isVisible = destinationId == R.id.navigation_dashboard } diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt index cc98a53..cf83791 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt @@ -6,19 +6,12 @@ import io.nekohasekai.sfa.R import io.nekohasekai.sfa.databinding.ActivityDebugBinding import io.nekohasekai.sfa.ui.shared.AbstractActivity -class DebugActivity : AbstractActivity() { - - private var binding: ActivityDebugBinding? = null +class DebugActivity : AbstractActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setTitle(R.string.title_debug) - val binding = ActivityDebugBinding.inflate(layoutInflater) - this.binding = binding - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - binding.scanVPNButton.setOnClickListener { startActivity(Intent(this, VPNScanActivity::class.java)) } diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt index 6de9151..2cb3a6f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt @@ -25,19 +25,14 @@ import java.io.File import java.util.zip.ZipFile import kotlin.math.roundToInt -class VPNScanActivity : AbstractActivity() { +class VPNScanActivity : AbstractActivity() { - private var binding: ActivityVpnScanBinding? = null private var adapter: Adapter? = null private val appInfoList = mutableListOf() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setTitle(R.string.title_scan_vpn) - val binding = ActivityVpnScanBinding.inflate(layoutInflater) - this.binding = binding - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) binding.scanVPNResult.adapter = Adapter().also { adapter = it } diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt index 4d6fd88..8022009 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt @@ -79,11 +79,11 @@ class DashboardFragment : Fragment(R.layout.fragment_dashboard) { override fun onStart() { super.onStart() - val activity = activity ?: return + val activityBinding = activity?.binding ?: return val binding = binding ?: return if (mediator != null) return mediator = TabLayoutMediator( - activity.binding.dashboardTabLayout, + activityBinding.dashboardTabLayout, binding.dashboardPager ) { tab, position -> tab.setText(Page.values()[position].titleRes) @@ -93,20 +93,21 @@ class DashboardFragment : Fragment(R.layout.fragment_dashboard) { override fun onDestroyView() { super.onDestroyView() mediator?.detach() + mediator = null binding = null } private fun enablePager() { val activity = activity ?: return val binding = binding ?: return - activity.binding.dashboardTabLayout.isVisible = true + activity.binding?.dashboardTabLayout?.isVisible = true binding.dashboardPager.isUserInputEnabled = true } private fun disablePager() { val activity = activity ?: return val binding = binding ?: return - activity.binding.dashboardTabLayout.isVisible = false + activity.binding?.dashboardTabLayout?.isVisible = false binding.dashboardPager.isUserInputEnabled = false binding.dashboardPager.setCurrentItem(0, false) } diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt index 44fcf98..53de9cb 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt @@ -16,7 +16,6 @@ import io.nekohasekai.sfa.databinding.ActivityEditProfileBinding import io.nekohasekai.sfa.ktx.addTextChangedListener import io.nekohasekai.sfa.ktx.errorDialogBuilder import io.nekohasekai.sfa.ktx.setSimpleItems -import io.nekohasekai.sfa.ktx.shareProfile import io.nekohasekai.sfa.ktx.text import io.nekohasekai.sfa.ui.shared.AbstractActivity import io.nekohasekai.sfa.utils.HTTPClient @@ -28,20 +27,13 @@ import java.io.File import java.text.DateFormat import java.util.Date -class EditProfileActivity : AbstractActivity() { +class EditProfileActivity : AbstractActivity() { - private var binding: ActivityEditProfileBinding? = null - private var _profile: Profile? = null - private val profile get() = _profile!! + private lateinit var profile: Profile override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setTitle(R.string.title_edit_profile) - val binding = ActivityEditProfileBinding.inflate(layoutInflater) - this.binding = binding - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - lifecycleScope.launch(Dispatchers.IO) { runCatching { loadProfile() @@ -55,18 +47,11 @@ class EditProfileActivity : AbstractActivity() { } } - override fun onDestroy() { - super.onDestroy() - binding = null - } - private suspend fun loadProfile() { - val binding = binding ?: return delay(200L) - val profileId = intent.getLongExtra("profile_id", -1L) if (profileId == -1L) error("invalid arguments") - _profile = ProfileManager.get(profileId) ?: error("invalid arguments") + profile = ProfileManager.get(profileId) ?: error("invalid arguments") withContext(Dispatchers.Main) { binding.name.text = profile.name binding.name.addTextChangedListener { @@ -203,16 +188,4 @@ class EditProfileActivity : AbstractActivity() { } } - private fun shareProfile(button: View) { - lifecycleScope.launch(Dispatchers.IO) { - try { - shareProfile(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - errorDialogBuilder(e).show() - } - } - } - } - } \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt index 0657972..d87758e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt @@ -21,28 +21,17 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -class EditProfileContentActivity : AbstractActivity() { +class EditProfileContentActivity : AbstractActivity() { - private var binding: ActivityEditProfileContentBinding? = null - private var _profile: Profile? = null - private val profile get() = _profile!! + private var profile: Profile? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setTitle(R.string.title_edit_configuration) - val binding = ActivityEditProfileContentBinding.inflate(layoutInflater) - this.binding = binding - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) binding.editor.language = JsonLanguage() loadConfiguration() } - override fun onDestroy() { - super.onDestroy() - binding = null - } - private fun loadConfiguration() { lifecycleScope.launch(Dispatchers.IO) { runCatching { @@ -120,7 +109,8 @@ class EditProfileContentActivity : AbstractActivity() { val profileId = intent.getLongExtra("profile_id", -1L) if (profileId == -1L) error("invalid arguments") - _profile = ProfileManager.get(profileId) ?: error("invalid arguments") + val profile = ProfileManager.get(profileId) ?: error("invalid arguments") + this.profile = profile val content = File(profile.typed.path).readText() withContext(Dispatchers.Main) { binding.editor.setTextContent(content) diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt index 6f2ab38..b0b9231 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt @@ -28,13 +28,12 @@ import java.io.File import java.io.InputStream import java.util.Date -class NewProfileActivity : AbstractActivity() { +class NewProfileActivity : AbstractActivity() { enum class FileSource(val formatted: String) { CreateNew("Create New"), Import("Import"); } - private var binding: ActivityAddProfileBinding? = null private val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { fileURI -> val binding = binding ?: return@registerForActivityResult @@ -47,10 +46,6 @@ class NewProfileActivity : AbstractActivity() { super.onCreate(savedInstanceState) setTitle(R.string.title_new_profile) - val binding = ActivityAddProfileBinding.inflate(layoutInflater) - this.binding = binding - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) intent.getStringExtra("importName")?.also { importName -> intent.getStringExtra("importURL")?.also { importURL -> @@ -100,11 +95,6 @@ class NewProfileActivity : AbstractActivity() { binding.autoUpdateInterval.addTextChangedListener(this::updateAutoUpdateInterval) } - override fun onDestroy() { - super.onDestroy() - binding = null - } - private fun createProfile(view: View) { val binding = binding ?: return if (binding.name.showErrorIfEmpty()) { diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt index ccf9553..e7333cc 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt @@ -3,7 +3,6 @@ package io.nekohasekai.sfa.ui.profileoverride import android.Manifest import android.annotation.SuppressLint import android.app.Dialog -import android.content.ClipData import android.content.ClipboardManager import android.content.pm.ApplicationInfo import android.content.pm.PackageManager @@ -20,7 +19,6 @@ import android.widget.TextView import android.widget.Toast import androidx.appcompat.widget.SearchView import androidx.core.content.getSystemService -import androidx.core.view.isGone import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -29,6 +27,7 @@ import io.nekohasekai.sfa.R import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.databinding.ActivityPerAppProxyBinding import io.nekohasekai.sfa.databinding.ViewAppListItemBinding +import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.ui.shared.AbstractActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -37,10 +36,9 @@ import org.jf.dexlib2.dexbacked.DexBackedDexFile import java.io.File import java.util.zip.ZipFile -class PerAppProxyActivity : AbstractActivity() { +class PerAppProxyActivity : AbstractActivity() { - private lateinit var binding: ActivityPerAppProxyBinding private lateinit var adapter: AppListAdapter private val perAppProxyList = mutableSetOf() @@ -52,10 +50,8 @@ class PerAppProxyActivity : AbstractActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setTitle(R.string.title_per_app_proxy) - binding = ActivityPerAppProxyBinding.inflate(layoutInflater) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) val proxyMode = Settings.perAppProxyMode if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { @@ -87,8 +83,8 @@ class PerAppProxyActivity : AbstractActivity() { @SuppressLint("NotifyDataSetChanged") private fun loadAppList() { - binding.recyclerViewAppList.isGone = true - binding.layoutProgress.isGone = false +// binding.recyclerViewAppList.isGone = true +// binding.layoutProgress.isGone = false lifecycleScope.launch { val list = withContext(Dispatchers.IO) { @@ -142,8 +138,8 @@ class PerAppProxyActivity : AbstractActivity() { adapter.notifyDataSetChanged() binding.recyclerViewAppList.scrollToPosition(0) - binding.layoutProgress.isGone = true - binding.recyclerViewAppList.isGone = false +// binding.layoutProgress.isGone = true +// binding.recyclerViewAppList.isGone = false } } @@ -183,7 +179,7 @@ class PerAppProxyActivity : AbstractActivity() { R.id.action_import -> { MaterialAlertDialogBuilder(this) - .setTitle(R.string.menu_import_from_clipboard) + .setTitle(R.string.per_app_proxy_import) .setMessage(R.string.message_import_from_clipboard) .setPositiveButton(R.string.ok) { _, _ -> importFromClipboard() @@ -247,9 +243,7 @@ class PerAppProxyActivity : AbstractActivity() { return } val content = perAppProxyList.joinToString("\n") - val clipboardManager = getSystemService()!! - val clip = ClipData.newPlainText(null, content) - clipboardManager.setPrimaryClip(clip) + clipboardText = content if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { Toast.makeText(this, R.string.toast_copied_to_clipboard, Toast.LENGTH_SHORT).show() } @@ -477,14 +471,14 @@ class PerAppProxyActivity : AbstractActivity() { ) : RecyclerView.ViewHolder(binding.root) { fun bind(item: AppItem) { - binding.imageAppIcon.setImageDrawable(item.icon) - binding.textAppName.text = item.name - binding.textAppPackageName.text = item.packageName - binding.checkboxAppSelected.isChecked = item.selected + binding.appIcon.setImageDrawable(item.icon) + binding.applicationLabel.text = item.name + binding.packageName.text = item.packageName + binding.selected.isChecked = item.selected } fun bindCheck(item: AppItem) { - binding.checkboxAppSelected.isChecked = item.selected + binding.selected.isChecked = item.selected } } } \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity0.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity0.kt new file mode 100644 index 0000000..3b2b82f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity0.kt @@ -0,0 +1,669 @@ +package io.nekohasekai.sfa.ui.profileoverride + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.databinding.ActivityPerAppProxy0Binding +import io.nekohasekai.sfa.databinding.DialogProgressbarBinding +import io.nekohasekai.sfa.databinding.ViewAppListItemBinding +import io.nekohasekai.sfa.ktx.clipboardText +import io.nekohasekai.sfa.ui.shared.AbstractActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jf.dexlib2.dexbacked.DexBackedDexFile +import java.io.File +import java.util.zip.ZipFile + +class PerAppProxyActivity0 : AbstractActivity() { + enum class SortMode { + NAME, PACKAGE_NAME, UID, INSTALL_TIME, UPDATE_TIME, + } + + private var proxyMode = Settings.PER_APP_PROXY_INCLUDE + private var sortMode = SortMode.NAME + private var sortReverse = false + private var hideSystemApps = false + private var hideOfflineApps = true + private var hideDisabledApps = true + + inner class PackageCache( + private val packageInfo: PackageInfo, + ) { + + val packageName: String get() = packageInfo.packageName + + val uid get() = packageInfo.applicationInfo.uid + + val installTime get() = packageInfo.firstInstallTime + val updateTime get() = packageInfo.lastUpdateTime + val isSystem get() = packageInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1 + val isOffline get() = packageInfo.requestedPermissions?.contains(Manifest.permission.INTERNET) != true + val isDisabled get() = packageInfo.applicationInfo.flags and ApplicationInfo.FLAG_INSTALLED == 0 + + val applicationIcon by lazy { + packageInfo.applicationInfo.loadIcon(packageManager) + } + + val applicationLabel by lazy { + packageInfo.applicationInfo.loadLabel(packageManager).toString() + } + } + + private lateinit var adapter: ApplicationAdapter + private var packages = listOf() + private var displayPackages = listOf() + private var currentPackages = listOf() + private var selectedUIDs = mutableSetOf() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_per_app_proxy) + + lifecycleScope.launch { + proxyMode = if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_EXCLUDE) { + Settings.PER_APP_PROXY_EXCLUDE + } else { + Settings.PER_APP_PROXY_INCLUDE + } + withContext(Dispatchers.Main) { + if (proxyMode != Settings.PER_APP_PROXY_EXCLUDE) { + binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_include_description) + } else { + binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_exclude_description) + } + } + reloadApplicationList() + filterApplicationList() + withContext(Dispatchers.Main) { + adapter = ApplicationAdapter(displayPackages) + binding.appList.adapter = adapter + delay(500L) + binding.progress.isVisible = false + } + } + } + + private fun reloadApplicationList() { + val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES + } else { + @Suppress("DEPRECATION") PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES + } + val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages( + PackageManager.PackageInfoFlags.of( + packageManagerFlags.toLong() + ) + ) + } else { + @Suppress("DEPRECATION") packageManager.getInstalledPackages(packageManagerFlags) + } + val packages = mutableListOf() + for (packageInfo in installedPackages) { + if (packageInfo.packageName == packageName) continue + packages.add(PackageCache(packageInfo)) + } + val selectedPackageNames = Settings.perAppProxyList.toMutableSet() + val selectedUIDs = mutableSetOf() + for (packageCache in packages) { + if (selectedPackageNames.contains(packageCache.packageName)) { + selectedUIDs.add(packageCache.uid) + } + } + this.packages = packages + this.selectedUIDs = selectedUIDs + } + + private fun filterApplicationList(selectedUIDs: Set = this.selectedUIDs) { + val displayPackages = mutableListOf() + for (packageCache in packages) { + if (hideSystemApps && packageCache.isSystem) continue + if (hideOfflineApps && packageCache.isOffline) continue + if (hideDisabledApps && packageCache.isDisabled) continue + displayPackages.add(packageCache) + } + displayPackages.sortWith(compareBy { + !selectedUIDs.contains(it.uid) + }.let { + if (!sortReverse) it.thenBy { + when (sortMode) { + SortMode.NAME -> it.applicationLabel + SortMode.PACKAGE_NAME -> it.packageName + SortMode.UID -> it.uid + SortMode.INSTALL_TIME -> it.installTime + SortMode.UPDATE_TIME -> it.updateTime + } + } else it.thenByDescending { + when (sortMode) { + SortMode.NAME -> it.applicationLabel + SortMode.PACKAGE_NAME -> it.packageName + SortMode.UID -> it.uid + SortMode.INSTALL_TIME -> it.installTime + SortMode.UPDATE_TIME -> it.updateTime + } + } + }) + + this.displayPackages = displayPackages + this.currentPackages = displayPackages + } + + private fun updateApplicationSelection(packageCache: PackageCache, selected: Boolean) { + val performed = if (selected) { + selectedUIDs.add(packageCache.uid) + } else { + selectedUIDs.remove(packageCache.uid) + } + if (!performed) return + currentPackages.forEachIndexed { index, it -> + if (it.uid == packageCache.uid) { + adapter.notifyItemChanged(index, PayloadUpdateSelection(selected)) + } + } + saveSelectedApplications() + } + + data class PayloadUpdateSelection(val selected: Boolean) + + inner class ApplicationAdapter(private var applicationList: List) : + RecyclerView.Adapter() { + + @SuppressLint("NotifyDataSetChanged") + fun setApplicationList(applicationList: List) { + this.applicationList = applicationList + notifyDataSetChanged() + } + + override fun onCreateViewHolder( + parent: ViewGroup, viewType: Int + ): ApplicationViewHolder { + return ApplicationViewHolder( + ViewAppListItemBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + } + + override fun getItemCount(): Int { + return applicationList.size + } + + override fun onBindViewHolder( + holder: ApplicationViewHolder, position: Int + ) { + holder.bind(applicationList[position]) + } + + override fun onBindViewHolder( + holder: ApplicationViewHolder, position: Int, payloads: MutableList + ) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + return + } + payloads.forEach { + when (it) { + is PayloadUpdateSelection -> holder.updateSelection(it.selected) + } + } + } + } + + inner class ApplicationViewHolder( + private val binding: ViewAppListItemBinding + ) : RecyclerView.ViewHolder(binding.root) { + + @SuppressLint("SetTextI18n") + fun bind(packageCache: PackageCache) { + binding.appIcon.setImageDrawable(packageCache.applicationIcon) + binding.applicationLabel.text = packageCache.applicationLabel + binding.packageName.text = "${packageCache.packageName} (${packageCache.uid})" + binding.selected.isChecked = selectedUIDs.contains(packageCache.uid) + binding.root.setOnClickListener { + updateApplicationSelection(packageCache, !binding.selected.isChecked) + } + binding.root.setOnLongClickListener { + val popup = PopupMenu(it.context, it) + popup.setForceShowIcon(true) + popup.gravity = Gravity.END + popup.menuInflater.inflate(R.menu.app_menu, popup.menu) + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_copy_application_label -> { + clipboardText = packageCache.applicationLabel + true + } + + R.id.action_copy_package_name -> { + clipboardText = packageCache.packageName + true + } + + R.id.action_copy_uid -> { + clipboardText = packageCache.uid.toString() + true + } + + else -> false + } + } + popup.show() + true + } + } + + fun updateSelection(selected: Boolean) { + binding.selected.isChecked = selected + } + } + + private fun searchApplications(searchText: String) { + currentPackages = if (searchText.isEmpty()) { + displayPackages + } else { + displayPackages.filter { + it.applicationLabel.contains( + searchText, ignoreCase = true + ) || it.packageName.contains( + searchText, ignoreCase = true + ) || it.uid.toString().contains(searchText) + } + } + adapter.setApplicationList(currentPackages) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.per_app_menu0, menu) + + if (menu != null) { + val searchView = menu.findItem(R.id.action_search).actionView as SearchView + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + searchApplications(newText) + return true + } + }) + searchView.setOnCloseListener { + searchApplications("") + true + } + when (proxyMode) { + Settings.PER_APP_PROXY_INCLUDE -> { + menu.findItem(R.id.action_mode_include).isChecked = true + } + + Settings.PER_APP_PROXY_EXCLUDE -> { + menu.findItem(R.id.action_mode_exclude).isChecked = true + } + } + when (sortMode) { + SortMode.NAME -> { + menu.findItem(R.id.action_sort_by_name).isChecked = true + } + + SortMode.PACKAGE_NAME -> { + menu.findItem(R.id.action_sort_by_package_name).isChecked = true + } + + SortMode.UID -> { + menu.findItem(R.id.action_sort_by_uid).isChecked = true + } + + SortMode.INSTALL_TIME -> { + menu.findItem(R.id.action_sort_by_install_time).isChecked = true + } + + SortMode.UPDATE_TIME -> { + menu.findItem(R.id.action_sort_by_update_time).isChecked = true + } + } + menu.findItem(R.id.action_sort_reverse).isChecked = sortReverse + menu.findItem(R.id.action_hide_system_apps).isChecked = hideSystemApps + menu.findItem(R.id.action_hide_offline_apps).isChecked = hideOfflineApps + menu.findItem(R.id.action_hide_disabled_apps).isChecked = hideDisabledApps + } + + return super.onCreateOptionsMenu(menu) + } + + @SuppressLint("NotifyDataSetChanged") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_mode_include -> { + item.isChecked = true + proxyMode = Settings.PER_APP_PROXY_INCLUDE + binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_include_description) + lifecycleScope.launch { + Settings.perAppProxyMode = Settings.PER_APP_PROXY_INCLUDE + } + } + + R.id.action_mode_exclude -> { + item.isChecked = true + proxyMode = Settings.PER_APP_PROXY_EXCLUDE + binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_exclude_description) + lifecycleScope.launch { + Settings.perAppProxyMode = Settings.PER_APP_PROXY_EXCLUDE + } + } + + R.id.action_sort_by_name -> { + item.isChecked = true + sortMode = SortMode.NAME + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_by_package_name -> { + item.isChecked = true + sortMode = SortMode.PACKAGE_NAME + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_by_uid -> { + item.isChecked = true + sortMode = SortMode.UID + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_by_install_time -> { + item.isChecked = true + sortMode = SortMode.INSTALL_TIME + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_by_update_time -> { + item.isChecked = true + sortMode = SortMode.UPDATE_TIME + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_sort_reverse -> { + item.isChecked = !item.isChecked + sortReverse = item.isChecked + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_hide_system_apps -> { + item.isChecked = !item.isChecked + hideSystemApps = item.isChecked + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_hide_offline_apps -> { + item.isChecked = !item.isChecked + hideOfflineApps = item.isChecked + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_hide_disabled_apps -> { + item.isChecked = !item.isChecked + hideDisabledApps = item.isChecked + filterApplicationList() + adapter.setApplicationList(currentPackages) + } + + R.id.action_select_all -> { + val selectedUIDs = mutableSetOf() + for (packageCache in packages) { + selectedUIDs.add(packageCache.uid) + } + this.selectedUIDs = selectedUIDs + saveSelectedApplications() + } + + R.id.action_deselect_all -> { + selectedUIDs = mutableSetOf() + saveSelectedApplications() + } + + R.id.action_export -> { + lifecycleScope.launch { + val packageList = mutableListOf() + for (packageCache in packages) { + if (selectedUIDs.contains(packageCache.uid)) { + packageList.add(packageCache.packageName) + } + } + clipboardText = packageList.joinToString("\n") + withContext(Dispatchers.Main) { + Toast.makeText( + this@PerAppProxyActivity0, + R.string.toast_copied_to_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + } + + R.id.action_import -> { + val packageNames = clipboardText?.split("\n")?.distinct() + ?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() } + if (packageNames.isNullOrEmpty()) { + Toast.makeText( + this@PerAppProxyActivity0, + R.string.toast_clipboard_empty, + Toast.LENGTH_SHORT + ).show() + return true + } + val selectedUIDs = mutableSetOf() + for (packageCache in packages) { + if (packageNames.contains(packageCache.packageName)) { + selectedUIDs.add(packageCache.uid) + } + } + lifecycleScope.launch { + postSaveSelectedApplications(selectedUIDs) + withContext(Dispatchers.Main) { + Toast.makeText( + this@PerAppProxyActivity0, + R.string.toast_imported_from_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + + } + + R.id.action_scan_china_apps -> { + scanChinaApps() + } + } + return true + } + + @SuppressLint("NotifyDataSetChanged") + private fun scanChinaApps() { + val binding = DialogProgressbarBinding.inflate(layoutInflater) + binding.progress.max = currentPackages.size + binding.message.setText(R.string.message_scanning) + val progress = MaterialAlertDialogBuilder( + this, com.google.android.material.R.style.Theme_MaterialComponents_Dialog + ).setView(binding.root).setCancelable(false).create() + progress.show() + lifecycleScope.launch { + val foundApps = withContext(Dispatchers.IO) { + mutableMapOf().also { foundApps -> + currentPackages.forEachIndexed { index, it -> + if (scanChinaPackage(it.packageName)) { + foundApps[it.packageName] = it + } + withContext(Dispatchers.Main) { + binding.progress.progress = index + 1 + } + } + } + } + withContext(Dispatchers.Main) { + progress.dismiss() + if (foundApps.isEmpty()) { + MaterialAlertDialogBuilder(this@PerAppProxyActivity0).setTitle(R.string.title_scan_result) + .setMessage(R.string.message_scan_app_no_apps_found) + .setPositiveButton(R.string.ok, null).show() + return@withContext + } + val dialogContent = + getString(R.string.message_scan_app_found) + "\n\n" + foundApps.entries.joinToString( + "\n" + ) { + "${it.value.applicationLabel} (${it.key})" + } + MaterialAlertDialogBuilder(this@PerAppProxyActivity0).setTitle(R.string.title_scan_result) + .setMessage(dialogContent) + .setPositiveButton(R.string.action_select) { dialog, _ -> + dialog.dismiss() + lifecycleScope.launch { + val selectedUIDs = selectedUIDs.toMutableSet() + foundApps.values.forEach { + selectedUIDs.add(it.uid) + } + postSaveSelectedApplications(selectedUIDs) + } + }.setNegativeButton(R.string.action_deselect) { dialog, _ -> + dialog.dismiss() + lifecycleScope.launch { + val selectedUIDs = selectedUIDs.toMutableSet() + foundApps.values.forEach { + selectedUIDs.remove(it.uid) + } + postSaveSelectedApplications(selectedUIDs) + } + }.setNeutralButton(android.R.string.cancel, null).show() + } + } + + } + + @SuppressLint("NotifyDataSetChanged") + private suspend fun postSaveSelectedApplications(newUIDs: MutableSet) { + filterApplicationList(newUIDs) + withContext(Dispatchers.Main) { + selectedUIDs = newUIDs + adapter.notifyDataSetChanged() + } + val packageList = selectedUIDs.mapNotNull { uid -> + packages.find { it.uid == uid }?.packageName + } + Settings.perAppProxyList = packageList.toSet() + } + + private fun saveSelectedApplications() { + lifecycleScope.launch { + val packageList = selectedUIDs.mapNotNull { uid -> + packages.find { it.uid == uid }?.packageName + } + Settings.perAppProxyList = packageList.toSet() + } + } + + companion object { + + private val chinaAppPrefixList by lazy { + runCatching { + Application.application.assets.open("prefix-china-apps.txt").reader().readLines() + }.getOrNull() ?: emptyList() + } + + private val chinaAppRegex by lazy { + ("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex() + } + + fun scanChinaPackage(packageName: String): Boolean { + val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } else { + @Suppress("DEPRECATION") PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } + if (packageName.matches(chinaAppRegex)) { + return true + } + try { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Application.packageManager.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong()) + ) + } else { + @Suppress("DEPRECATION") Application.packageManager.getPackageInfo( + packageName, packageManagerFlags + ) + } + if (packageInfo.services?.find { it.name.matches(chinaAppRegex) } != null || packageInfo.activities?.find { + it.name.matches( + chinaAppRegex + ) + } != null || packageInfo.receivers?.find { it.name.matches(chinaAppRegex) } != null || packageInfo.providers?.find { + it.name.matches( + chinaAppRegex + ) + } != null) { + return true + } + ZipFile(File(packageInfo.applicationInfo.publicSourceDir)).use { + for (packageEntry in it.entries()) { + if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith( + ".dex" + )) + ) { + continue + } + if (packageEntry.size > 15000000) { + return true + } + val input = it.getInputStream(packageEntry).buffered() + val dexFile = try { + DexBackedDexFile.fromInputStream(null, input) + } catch (e: Exception) { + return false + } + for (clazz in dexFile.classes) { + val clazzName = + clazz.type.substring(1, clazz.type.length - 1).replace("/", ".") + .replace("$", ".") + if (clazzName.matches(chinaAppRegex)) { + return true + } + } + } + } + } catch (ignored: Exception) { + } + return false + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt index 0ada5fc..1598b53 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt @@ -15,17 +15,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class ProfileOverrideActivity : AbstractActivity() { - - private lateinit var binding: ActivityConfigOverrideBinding +class ProfileOverrideActivity : + AbstractActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setTitle(R.string.title_profile_override) - binding = ActivityConfigOverrideBinding.inflate(layoutInflater) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setTitle(R.string.title_profile_override) binding.switchPerAppProxy.isChecked = Settings.perAppProxyEnabled binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked -> Settings.perAppProxyEnabled = isChecked @@ -42,7 +38,7 @@ class ProfileOverrideActivity : AbstractActivity() { } binding.configureAppListButton.setOnClickListener { - startActivity(Intent(this, PerAppProxyActivity::class.java)) + startActivity(Intent(this, PerAppProxyActivity0::class.java)) } lifecycleScope.launch(Dispatchers.IO) { reloadSettings() diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt index 82231cc..2ba7da0 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt @@ -1,16 +1,26 @@ package io.nekohasekai.sfa.ui.shared import android.os.Bundle +import android.view.LayoutInflater import android.view.MenuItem import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources +import androidx.viewbinding.ViewBinding +import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.color.DynamicColors import io.nekohasekai.sfa.R import io.nekohasekai.sfa.ktx.getAttrColor +import io.nekohasekai.sfa.ui.MainActivity import io.nekohasekai.sfa.utils.MIUIUtils +import java.lang.reflect.ParameterizedType + +abstract class AbstractActivity() : + AppCompatActivity() { + + private var _binding: Binding? = null + internal val binding get() = _binding!! -abstract class AbstractActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -21,17 +31,28 @@ abstract class AbstractActivity : AppCompatActivity() { window.statusBarColor = colorSurfaceContainer window.navigationBarColor = colorSurfaceContainer + _binding = createBindingInstance(layoutInflater).also { + setContentView(it.root) + } + + findViewById(R.id.toolbar)?.also { + setSupportActionBar(it) + } + // MIUI overrides colorSurfaceContainer to colorSurface without below flags @Suppress("DEPRECATION") if (MIUIUtils.isMIUI) { window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) } - supportActionBar?.setHomeAsUpIndicator(AppCompatResources.getDrawable( - this@AbstractActivity, R.drawable.ic_arrow_back_24 - )!!.apply { - setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) - }) + if (this !is MainActivity) { + supportActionBar?.setHomeAsUpIndicator(AppCompatResources.getDrawable( + this@AbstractActivity, R.drawable.ic_arrow_back_24 + )!!.apply { + setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) + }) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -44,4 +65,14 @@ abstract class AbstractActivity : AppCompatActivity() { return super.onOptionsItemSelected(item) } + @Suppress("UNCHECKED_CAST") + private fun createBindingInstance( + inflater: LayoutInflater, + ): Binding { + val vbType = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] + val vbClass = vbType as Class + val method = vbClass.getMethod("inflate", LayoutInflater::class.java) + return method.invoke(null, inflater) as Binding + } + } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_profile.xml b/app/src/main/res/layout/activity_add_profile.xml index 1f51b2f..383e152 100644 --- a/app/src/main/res/layout/activity_add_profile.xml +++ b/app/src/main/res/layout/activity_add_profile.xml @@ -1,175 +1,185 @@ - + android:layout_width="match_parent" + android:layout_height="match_parent"> - + + + android:layout_height="match_parent" + app:layout_behavior="@string/appbar_scrolling_view_behavior"> - + android:layout_height="wrap_content" + android:orientation="vertical"> - - - - - - - - - - - + android:indeterminate="true" + android:visibility="gone" /> + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false" + android:orientation="vertical" + android:padding="16dp"> + + + + + + + android:hint="@string/profile_type"> + android:text="@string/profile_type_local" + app:simpleItems="@array/profile_type" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_config_override.xml b/app/src/main/res/layout/activity_config_override.xml index 0839237..163350e 100644 --- a/app/src/main/res/layout/activity_config_override.xml +++ b/app/src/main/res/layout/activity_config_override.xml @@ -1,89 +1,98 @@ - - + + + android:layout_height="match_parent" + app:layout_behavior="@string/appbar_scrolling_view_behavior"> - + android:layout_height="wrap_content" + android:clipChildren="false" + android:clipToPadding="false" + android:orientation="vertical" + android:padding="16dp"> - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content"> + android:orientation="vertical" + android:paddingStart="16dp" + android:paddingTop="16dp" + android:paddingEnd="16dp" + android:paddingBottom="8dp"> -