owenclave / app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.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.app.Activity import android.content.DialogInterface import android.content.Intent import android.graphics.Color import android.os.Bundle import android.provider.OpenableColumns import android.view.* import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import io.nekohasekai.sagernet.* import io.nekohasekai.sagernet.aidl.TrafficStats import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.test.V2RayTestInstance import io.nekohasekai.sagernet.database.* import io.nekohasekai.sagernet.databinding.LayoutProfileBinding import io.nekohasekai.sagernet.databinding.LayoutProfileListBinding import io.nekohasekai.sagernet.databinding.LayoutProgressListBinding import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.exportBackup import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.wireguard.toConf import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.group.Protocols import io.nekohasekai.sagernet.group.RawUpdater import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.ui.profile.* import io.nekohasekai.sagernet.widget.QRCodeDialog import io.nekohasekai.sagernet.widget.UndoSnackbarManager import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.* import java.util.concurrent.ConcurrentLinkedQueue import java.util.zip.ZipInputStream import kotlin.concurrent.timerTask import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.fmt.internal.BalancerBean import io.nekohasekai.sagernet.fmt.internal.ChainBean import io.nekohasekai.sagernet.utils.FormatFileSizeCompat @android.annotation.SuppressLint("ClickableViewAccessibility") fun View.suppressDragWhilePressed(setPressed: (Boolean) -> Unit) { setOnTouchListener { _, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> setPressed(true) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> setPressed(false) } false } } class ConfigurationFragment @JvmOverloads constructor( val select: Boolean = false, val selectedItem: ProxyEntity? = null, val titleRes: Int = 0 ) : ToolbarFragment(R.layout.layout_group_list), PopupMenu.OnMenuItemClickListener, Toolbar.OnMenuItemClickListener { val onBackPressedCallback = object : OnBackPressedCallback(enabled = false) { override fun handleOnBackPressed() { searchView?.onActionViewCollapsed() searchView?.clearFocus() } } interface SelectCallback { fun returnProfile(profileId: Long) } lateinit var adapter: GroupPagerAdapter lateinit var tabLayout: TabLayout lateinit var groupPager: ViewPager2 var searchView: SearchView? = null val selectedGroup get() = if (tabLayout.isGone && adapter.groupList.size > 0) adapter.groupList[0] else (if (adapter.groupList.size > 0 && tabLayout.selectedTabPosition > -1) adapter.groupList[tabLayout.selectedTabPosition] else ProxyGroup()) val alwaysShowAddress by lazy { DataStore.alwaysShowAddress } val updateSelectedCallback = object : ViewPager2.OnPageChangeCallback() { override fun onPageScrolled( position: Int, positionOffset: Float, positionOffsetPixels: Int ) { if (adapter.groupList.size > position) { DataStore.selectedGroup = adapter.groupList[position].id } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { parentFragmentManager.beginTransaction() .setReorderingAllowed(false) .detach(this) .attach(this) .commit() } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) if (!select) { toolbar.inflateMenu(R.menu.add_profile_menu) toolbar.setOnMenuItemClickListener(this) } else { toolbar.setTitle(titleRes) toolbar.setNavigationIcon(R.drawable.ic_navigation_close) toolbar.setNavigationOnClickListener { requireActivity().finish() } } ViewCompat.setOnApplyWindowInsetsListener(view.findViewById(R.id.group_tab)) { v, insets -> val bars = insets.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() ) v.updatePadding( left = bars.left, right = bars.right, ) insets } searchView = toolbar.findViewById(R.id.action_search) searchView?.maxWidth = Int.MAX_VALUE searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?) = false override fun onQueryTextChange(query: String?): Boolean { query?.let { try { val fragment = (childFragmentManager.findFragmentByTag("f" + selectedGroup.id) as GroupFragment?) fragment?.adapter?.filter(query) } catch (_: Exception) {} } return false } }) searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> onBackPressedCallback.isEnabled = hasFocus } searchView?.let { // override onBackPressedCallback of MainActivity (requireActivity() as? MainActivity)?.onBackPressedDispatcher?.addCallback(this, onBackPressedCallback) } groupPager = view.findViewById(R.id.group_pager) tabLayout = view.findViewById(R.id.group_tab) adapter = GroupPagerAdapter() ProfileManager.addListener(adapter) GroupManager.addListener(adapter) groupPager.adapter = adapter groupPager.offscreenPageLimit = 2 tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { searchView?.onActionViewCollapsed() searchView?.clearFocus() } override fun onTabUnselected(tab: TabLayout.Tab) { val fragment = (childFragmentManager.findFragmentByTag("f" + selectedGroup.id) as GroupFragment?) fragment?.adapter?.filter("") } override fun onTabReselected(tab: TabLayout.Tab) {} }) TabLayoutMediator(tabLayout, groupPager) { tab, position -> if (adapter.groupList.size > position) { tab.text = adapter.groupList[position].displayName() } tab.view.setOnLongClickListener { // clear toast true } }.attach() toolbar.setOnClickListener { val fragment = (childFragmentManager.findFragmentByTag("f" + selectedGroup.id) as GroupFragment?) if (fragment != null) { val selectedProxy = selectedItem?.id ?: DataStore.selectedProxy val selectedProfileIndex = fragment.adapter.configurationIdList.indexOf( selectedProxy ) if (selectedProfileIndex != -1) { val layoutManager = fragment.layoutManager val first = layoutManager.findFirstVisibleItemPosition() val last = layoutManager.findLastVisibleItemPosition() if (selectedProfileIndex !in first..last) { fragment.configurationListView.scrollTo(selectedProfileIndex, true) return@setOnClickListener } } fragment.configurationListView.scrollTo(0) } } toolbar.setOnLongClickListener { val selectedProxy = selectedItem ?: SagerDatabase.proxyDao.getById(DataStore.selectedProxy) ?: return@setOnLongClickListener true val groupIndex = adapter.groupList.indexOfFirst { it.id == selectedProxy.groupId } if (groupIndex < 0) return@setOnLongClickListener true DataStore.selectedGroup = selectedProxy.groupId groupPager.currentItem = groupIndex val fragment = (childFragmentManager.findFragmentByTag("f" + selectedGroup.id) as GroupFragment?) if (fragment != null) { val selectedProfileIndex = fragment.adapter.configurationIdList.indexOfFirst { it == selectedProxy.id } if (selectedProfileIndex > 0) { fragment.configurationListView.scrollTo(selectedProfileIndex, true) } } true } (requireActivity() as? MainActivity)?.onBackPressedCallback?.isEnabled = false } override fun onDestroy() { if (::adapter.isInitialized) { GroupManager.removeListener(adapter) ProfileManager.removeListener(adapter) } super.onDestroy() } override fun onKeyDown(ketCode: Int, event: KeyEvent): Boolean { val fragment = (childFragmentManager.findFragmentByTag("f" + selectedGroup.id) as GroupFragment?) fragment?.configurationListView?.apply { if (!hasFocus()) requestFocus() } return super.onKeyDown(ketCode, event) } val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> var fileText = "" if (file != null) runOnDefaultDispatcher { try { val fileName = requireContext().contentResolver.query(file, null, null, null, null) ?.use { cursor -> cursor.moveToFirst() cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) .let(cursor::getString) } val proxies = mutableListOf() if (fileName != null && fileName.endsWith(".zip")) { // try parse wireguard zip val zip = ZipInputStream(requireContext().contentResolver.openInputStream(file)!!) while (true) { val entry = zip.nextEntry ?: break if (entry.isDirectory) continue fileText = zip.bufferedReader().readText() RawUpdater.parseRaw(fileText)?.let { pl -> proxies.addAll(pl) } zip.closeEntry() } runCatching { zip.close() } } else { fileText = requireContext().contentResolver.openInputStream(file)!!.use { it.bufferedReader().readText() } RawUpdater.parseRaw(fileText)?.let { pl -> proxies.addAll(pl) } } if (proxies.isEmpty()) { if (!fileText.contains("\n") && !fileText.contains("\r") && isHTTPorHTTPSURL(fileText)) { (requireActivity() as? MainActivity)?.importSubscription(fileText) } else { onMainDispatcher { snackbar(getString(R.string.no_proxies_found_in_file)).show() } } } else import(proxies) } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } val importBackupFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> if (file != null) runOnDefaultDispatcher { try { val text = requireContext().contentResolver.openInputStream(file)!!.use { it.bufferedReader().readText() } val proxies = parseBackupLines(text) if (proxies.isNotEmpty()) { import(proxies) } else onMainDispatcher { snackbar(getString(R.string.no_proxies_found_in_file)).show() } } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } suspend fun import(proxies: List) { if (proxies.size <= 32) { finishImport(proxies) } else { val name = SagerDatabase.groupDao.getById(DataStore.selectedGroupForImport())!!.displayName() onMainDispatcher { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.profile_import) .setMessage(resources.getQuantityString(R.plurals.profile_multi_import_message, proxies.size, proxies.size, name)) .setPositiveButton(android.R.string.ok) { _, _ -> runOnDefaultDispatcher { finishImport(proxies) } } .setNegativeButton(android.R.string.cancel, null) .show() } } } private suspend fun finishImport(proxies: List) { val targetId = DataStore.selectedGroupForImport() val targetIndex = adapter.groupList.indexOfFirst { it.id == targetId } for (proxy in proxies) { ProfileManager.createProfile(targetId, proxy) } onMainDispatcher { if (adapter.groupList.isEmpty() || selectedGroup.id != targetId) { if (targetIndex != -1) { tabLayout.getTabAt(targetIndex)?.select() } else { DataStore.selectedGroup = targetId adapter.reload() } } snackbar( requireContext().resources.getQuantityString( R.plurals.added, proxies.size, proxies.size ) ).show() val group = SagerDatabase.groupDao.getById(targetId)!! GroupManager.updateGroup(group, reconfigureUpdater = false) } } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_scan_qr_code -> { startActivity(Intent(context, ScannerActivity::class.java)) } R.id.action_import_clipboard -> { val text = SagerNet.getClipboardText() if (text.isBlank()) { snackbar(getString(R.string.clipboard_empty)).show() } else { runOnDefaultDispatcher { try { val proxies = RawUpdater.parseRaw(text) if (proxies.isNullOrEmpty()) { if (!text.contains("\n") && !text.contains("\r") && isHTTPorHTTPSURL(text)) { (requireActivity() as? MainActivity)?.importSubscription(text) } else onMainDispatcher { snackbar(getString(R.string.no_proxies_found_in_clipboard)).show() } } else { import(proxies) } } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } } R.id.action_import_file -> { startFilesForResult(importFile, "*/*") } R.id.action_import_backup_clipboard -> { val text = SagerNet.getClipboardText() if (text.isBlank()) { snackbar(getString(R.string.clipboard_empty)).show() } else { runOnDefaultDispatcher { try { val proxies = parseBackupLines(text) if (proxies.isNotEmpty()) { import(proxies) } else onMainDispatcher { snackbar(getString(R.string.no_proxies_found_in_file)).show() } } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } } R.id.action_import_backup_file -> { startFilesForResult(importBackupFile, "*/*") } R.id.action_new_socks -> { startActivity(Intent(requireActivity(), SocksSettingsActivity::class.java)) } R.id.action_new_http -> { startActivity(Intent(requireActivity(), HttpSettingsActivity::class.java)) } R.id.action_new_ss -> { startActivity(Intent(requireActivity(), ShadowsocksSettingsActivity::class.java)) } R.id.action_new_ssr -> { startActivity(Intent(requireActivity(), ShadowsocksRSettingsActivity::class.java)) } R.id.action_new_vmess -> { startActivity(Intent(requireActivity(), VMessSettingsActivity::class.java)) } R.id.action_new_vless -> { startActivity(Intent(requireActivity(), VLESSSettingsActivity::class.java)) } R.id.action_new_trojan -> { startActivity(Intent(requireActivity(), TrojanSettingsActivity::class.java)) } R.id.action_new_naive -> { startActivity(Intent(requireActivity(), NaiveSettingsActivity::class.java)) } R.id.action_new_hysteria2 -> { startActivity(Intent(requireActivity(), Hysteria2SettingsActivity::class.java)) } R.id.action_new_mieru -> { startActivity(Intent(requireActivity(), MieruSettingsActivity::class.java)) } R.id.action_new_tuic5 -> { startActivity(Intent(requireActivity(), Tuic5SettingsActivity::class.java)) } R.id.action_new_ssh -> { startActivity(Intent(requireActivity(), SSHSettingsActivity::class.java)) } R.id.action_new_wg -> { startActivity(Intent(requireActivity(), WireGuardSettingsActivity::class.java)) } R.id.action_new_juicity -> { startActivity(Intent(requireActivity(), JuicitySettingsActivity::class.java)) } R.id.action_new_http3 -> { startActivity(Intent(requireActivity(), Http3SettingsActivity::class.java)) } R.id.action_new_anytls -> { startActivity(Intent(requireActivity(), AnyTLSSettingsActivity::class.java)) } R.id.action_new_shadowquic -> { startActivity(Intent(requireActivity(), ShadowQUICSettingsActivity::class.java)) } R.id.action_new_trusttunnel -> { startActivity(Intent(requireActivity(), TrustTunnelSettingsActivity::class.java)) } R.id.action_new_snell -> { startActivity(Intent(requireActivity(), SnellSettingsActivity::class.java)) } R.id.action_new_olcrtc -> { startActivity(Intent(requireActivity(), io.nekohasekai.sagernet.ui.profile.OLCRTCSettingsActivity::class.java)) } R.id.action_new_config -> { startActivity(Intent(requireActivity(), ConfigSettingsActivity::class.java)) } R.id.action_new_chain -> { startActivity(Intent(requireActivity(), ChainSettingsActivity::class.java)) } R.id.action_new_balancer -> { startActivity(Intent(requireActivity(), BalancerSettingsActivity::class.java)) } R.id.action_clear_traffic_statistics -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val toClear = mutableListOf() if (profiles.isNotEmpty()) for (profile in profiles) { if (profile.tx != 0L || profile.rx != 0L) { profile.tx = 0 profile.rx = 0 toClear.add(profile) } } if (toClear.isNotEmpty()) { ProfileManager.updateProfile(toClear) } } } R.id.action_connection_test_clear_results -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val toClear = mutableListOf() if (profiles.isNotEmpty()) for (profile in profiles) { if (profile.status != 0) { profile.status = 0 profile.ping = 0 profile.error = null toClear.add(profile) } } if (toClear.isNotEmpty()) { ProfileManager.updateProfile(toClear) } } } R.id.action_remove_duplicate -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val toClear = mutableListOf() val uniqueProxies = LinkedHashSet() for (p in profiles) { val proxy = Protocols.Deduplication(p.requireBean(), p.displayType()) if (!uniqueProxies.add(proxy)) { toClear += p } } if (toClear.isNotEmpty()) { onMainDispatcher { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) .setMessage( getString(R.string.delete_multi_confirm_prompt) + "\n" + toClear.mapIndexedNotNull { index, proxyEntity -> if (index < 20) { proxyEntity.displayName() } else if (index == 20) { "......" } else { null } }.joinToString("\n") ) .setPositiveButton(android.R.string.ok) { _, _ -> for (profile in toClear) { adapter.groupFragments[DataStore.selectedGroup]?.adapter?.apply { val index = configurationIdList.indexOf(profile.id) if (index >= 0) { configurationIdList.removeAt(index) configurationList.remove(profile.id) notifyItemRemoved(index) } } } runOnDefaultDispatcher { for (profile in toClear) { ProfileManager.deleteProfile2( profile.groupId, profile.id ) } } } .setNegativeButton(android.R.string.cancel, null) .show() } } } } R.id.action_connection_test_delete_unavailable -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) val toClear = mutableListOf() if (profiles.isNotEmpty()) for (profile in profiles) { if (profile.status != -1 && profile.status != 0 && profile.status != 1) { toClear.add(profile) } } if (toClear.isNotEmpty()) { onMainDispatcher { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) .setMessage( getString(R.string.delete_multi_confirm_prompt) + "\n" + toClear.mapIndexedNotNull { index, proxyEntity -> if (index < 20) { proxyEntity.displayName() } else if (index == 20) { "......" } else { null } }.joinToString("\n") ) .setPositiveButton(android.R.string.ok) { _, _ -> for (profile in toClear) { adapter.groupFragments[DataStore.selectedGroup]?.adapter?.apply { val index = configurationIdList.indexOf(profile.id) if (index >= 0) { configurationIdList.removeAt(index) configurationList.remove(profile.id) notifyItemRemoved(index) } } } runOnDefaultDispatcher { for (profile in toClear) { ProfileManager.deleteProfile2( profile.groupId, profile.id ) } } } .setNegativeButton(android.R.string.cancel, null) .show() } } } } R.id.action_connection_url_test -> { urlTest() } R.id.action_update_subscription -> { runOnDefaultDispatcher { val currentGroup = DataStore.currentGroup() if (currentGroup.type == GroupType.SUBSCRIPTION) { if (currentGroup.id !in GroupUpdater.updating) { GroupUpdater.startUpdate(currentGroup, true) } } else { snackbar(R.string.group_not_a_subscription).show() } } } } return true } inner class TestDialog { val binding = LayoutProgressListBinding.inflate(layoutInflater) val builder = MaterialAlertDialogBuilder(requireContext()).setView(binding.root) .setNegativeButton(android.R.string.cancel) { _, _ -> close() cancel() } .setNeutralButton(" ", null) .setCancelable(false) lateinit var cancel: () -> Unit val results = ArrayList() val adapter = TestAdapter() val scrollTimer = Timer("insert timer") var currentTask: TimerTask? = null fun insert(profile: ProxyEntity) { binding.listView.post { results.add(profile) val index = results.size - 1 adapter.notifyItemInserted(index) try { scrollTimer.schedule(timerTask { binding.listView.post { if (currentTask == this) binding.listView.smoothScrollToPosition(index) } }.also { currentTask?.cancel() currentTask = it }, 500L) } catch (ignored: Exception) { } } } fun update(profile: ProxyEntity) { binding.listView.post { val index = results.indexOf(profile) adapter.notifyItemChanged(index) } } fun close() { try { scrollTimer.schedule(timerTask { scrollTimer.cancel() }, 0) } catch (ignored: Exception) { } } init { binding.listView.layoutManager = FixedLinearLayoutManager(binding.listView) binding.listView.itemAnimator = DefaultItemAnimator() binding.listView.adapter = adapter } inner class TestAdapter : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TestResultHolder(LayoutProfileBinding.inflate(layoutInflater, parent, false)) override fun onBindViewHolder(holder: TestResultHolder, position: Int) { holder.bind(results[position]) } override fun getItemCount() = results.size } inner class TestResultHolder(val binding: LayoutProfileBinding) : RecyclerView.ViewHolder( binding.root ) { init { binding.edit.isGone = true binding.share.isGone = true binding.deleteIcon.isGone = true } fun bind(profile: ProxyEntity) { binding.profileName.text = profile.displayName() binding.profileType.text = profile.displayType() when (profile.status) { -1 -> { binding.profileStatus.text = profile.error binding.profileStatus.setTextColor(requireContext().getColorAttr(android.R.attr.textColorSecondary)) } 0 -> { binding.profileStatus.setText(R.string.connection_test_testing) binding.profileStatus.setTextColor(requireContext().getColorAttr(android.R.attr.textColorSecondary)) } 1 -> { binding.profileStatus.text = getString(R.string.available, profile.ping) binding.profileStatus.setTextColor(requireContext().getColour(R.color.material_green_500)) } 2 -> { binding.profileStatus.text = profile.error binding.profileStatus.setTextColor(requireContext().getColour(R.color.material_red_500)) } 3 -> { binding.profileStatus.setText(R.string.unavailable) binding.profileStatus.setTextColor(requireContext().getColour(R.color.material_red_500)) } } if (profile.status == 3) { binding.content.setOnClickListener { alert(profile.error ?: "").show() } } else { binding.content.setOnClickListener {} } } } } private fun ProxyEntity.useBrowserForwarder(): Boolean { return when (val bean = requireBean()) { is StandardV2RayBean -> { when (bean.type) { "ws" -> bean.wsUseBrowserForwarder "splithttp" -> bean.shUseBrowserForwarder else -> false } } is ChainBean -> { SagerDatabase.proxyDao.getEntities(bean.proxies).any { it.useBrowserForwarder() } } is BalancerBean -> { SagerDatabase.proxyDao.getEntities(bean.proxies).any { it.useBrowserForwarder() } } else -> false } } @Suppress("EXPERIMENTAL_API_USAGE") fun urlTest() { val test = TestDialog() val dialog = test.builder.show() dialog.getButton(DialogInterface.BUTTON_NEUTRAL).isEnabled = false val testJobs = mutableListOf() val mainJob = runOnDefaultDispatcher { val group = DataStore.currentGroup() var profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id) profilesUnfiltered = profilesUnfiltered.filter { !it.useBrowserForwarder() } val profiles = ConcurrentLinkedQueue(profilesUnfiltered) val profileCount = profilesUnfiltered.size var finishedProfileCount = 0 //stopService() val link = DataStore.connectionTestURL val timeout = 5000 repeat(6) { testJobs.add(launch { while (isActive) { val profile = profiles.poll() ?: break profile.status = 0 test.insert(profile) try { val instance = if (DataStore.tunImplementation == TunImplementation.SYSTEM && DataStore.serviceMode == Key.MODE_VPN && SagerNet.started && DataStore.startedProfile > 0) { V2RayTestInstance(profile, link, timeout, protectPath = SagerNet.deviceStorage.noBackupFilesDir.toString() + "/protect_path") } else { V2RayTestInstance(profile, link, timeout) } val result = instance.use { it.doTest() } profile.status = 1 profile.ping = result } catch (e: PluginManager.PluginNotFoundException) { profile.status = -1 profile.error = e.readableMessage } catch (e: Exception) { profile.status = 3 profile.error = e.readableMessage } onMainDispatcher { finishedProfileCount++ test.binding.progressCircular.apply { isVisible = true setProgressCompat( ((finishedProfileCount.toDouble() / profileCount.toDouble()) * 100).toInt(), true ) } // TODO: fix l10n dialog.getButton(DialogInterface.BUTTON_NEUTRAL).text = "$finishedProfileCount/$profileCount" } test.update(profile) ProfileManager.updateProfile(profile) } }) } testJobs.joinAll() test.close() onMainDispatcher { test.binding.progressCircular.isGone = true dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setText(android.R.string.ok) } } test.cancel = { mainJob.cancel() runOnDefaultDispatcher { GroupManager.postReload(DataStore.currentGroupId()) } } } inner class GroupPagerAdapter : FragmentStateAdapter(this), ProfileManager.Listener, GroupManager.Listener { var selectedGroupIndex = 0 var groupList: ArrayList = ArrayList() var groupFragments: HashMap = HashMap() fun reload(now: Boolean = false) { if (!select) { groupPager.unregisterOnPageChangeCallback(updateSelectedCallback) } runOnDefaultDispatcher { var newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) if (newGroupList.isEmpty()) { SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true)) newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) } newGroupList.find { it.ungrouped }?.let { if (SagerDatabase.proxyDao.countByGroup(it.id) == 0L) { newGroupList.remove(it) } } var selectedGroup = selectedItem?.groupId ?: DataStore.currentGroupId() var set = false if (selectedGroup > 0L) { selectedGroupIndex = newGroupList.indexOfFirst { it.id == selectedGroup } set = true } else if (groupList.size == 1) { selectedGroup = groupList[0].id if (DataStore.selectedGroup != selectedGroup) { DataStore.selectedGroup = selectedGroup } } try { requireActivity() } catch (e: Exception) { Logs.w(e) return@runOnDefaultDispatcher } val runFunc = if (now) requireActivity()::runOnUiThread else groupPager::post runFunc { groupList = newGroupList notifyDataSetChanged() if (set) groupPager.setCurrentItem(selectedGroupIndex, false) val hideTab = groupList.size < 2 tabLayout.isGone = hideTab toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat() if (!select) { groupPager.registerOnPageChangeCallback(updateSelectedCallback) } } } } init { reload(true) } override fun getItemCount(): Int { return groupList.size } override fun createFragment(position: Int): Fragment { return GroupFragment().apply { proxyGroup = groupList[position] groupFragments[proxyGroup.id] = this if (position == selectedGroupIndex) { selected = true } } } override fun getItemId(position: Int): Long { return groupList[position].id } override fun containsItem(itemId: Long): Boolean { return groupList.any { it.id == itemId } } override suspend fun groupAdd(group: ProxyGroup) { tabLayout.post { groupList.add(group) if (groupList.any { !it.ungrouped }) tabLayout.post { tabLayout.visibility = View.VISIBLE } notifyItemInserted(groupList.size - 1) tabLayout.getTabAt(groupList.size - 1)?.select() } } override suspend fun groupRemoved(groupId: Long) { tabLayout.post { val index = groupList.indexOfFirst { it.id == groupId } if (index == -1) return@post groupList.removeAt(index) notifyItemRemoved(index) } } override suspend fun groupUpdated(group: ProxyGroup) { val index = groupList.indexOfFirst { it.id == group.id } if (index == -1) return tabLayout.post { tabLayout.getTabAt(index)?.text = group.displayName() } } override suspend fun groupUpdated(groupId: Long) = Unit override suspend fun onAdd(profile: ProxyEntity) { if (groupList.find { it.id == profile.groupId } == null) { DataStore.selectedGroup = profile.groupId reload() } } override suspend fun onUpdated(profileId: Long, trafficStats: TrafficStats) = Unit override suspend fun onUpdated(profile: ProxyEntity) = Unit override suspend fun onRemoved(groupId: Long, profileId: Long) { val group = groupList.find { it.id == groupId } ?: return if (group.ungrouped && SagerDatabase.proxyDao.countByGroup(groupId) == 0L) { reload() } } } class GroupFragment : Fragment() { lateinit var proxyGroup: ProxyGroup var selected = false var scrolled = false val showBackup = DataStore.experimentalFlagsProperties.getBooleanProperty("enableProfileBackup") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { return LayoutProfileListBinding.inflate(inflater).root } lateinit var undoManager: UndoSnackbarManager lateinit var adapter: ConfigurationAdapter override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) if (::proxyGroup.isInitialized) { outState.putParcelable("proxyGroup", proxyGroup) } if (::layoutManager.isInitialized) { outState.putInt("scrollPosition", layoutManager.findFirstVisibleItemPosition()) } } override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) savedInstanceState?.getParcelable("proxyGroup")?.also { proxyGroup = it onViewCreated(requireView(), null) } savedInstanceState?.getInt("scrollPosition", -1)?.takeIf { it >= 0 }?.let { pos -> scrolled = true configurationListView.scrollToPosition(pos) } } private val isEnabled: Boolean get() { return ((activity as? MainActivity) ?: return false).state.let { it.canStop || it == BaseService.State.Stopped } } private var actionButtonPressed = false private fun isProfileEditable(id: Long): Boolean { return ((activity as? MainActivity) ?: return false).state == BaseService.State.Stopped || id != DataStore.selectedProxy } lateinit var layoutManager: LinearLayoutManager lateinit var configurationListView: RecyclerView val parent get() = parentFragment as? ConfigurationFragment fun requirePrent() = requireParentFragment() as ConfigurationFragment override fun onResume() { super.onResume() if (::adapter.isInitialized) { if (adapter.itemCount == 0) { runOnDefaultDispatcher { adapter.reloadProfiles() } } } else { onViewCreated(requireView(), null) } checkOrderMenu() if (!DataStore.experimentalFlagsProperties.getBooleanProperty("shadowquic")) { (parentFragment as? ToolbarFragment) ?.toolbar?.menu?.findItem(R.id.action_new_shadowquic)?.isVisible = false } if (showBackup) { (parentFragment as? ToolbarFragment) ?.toolbar?.menu?.findItem(R.id.action_import_backup)?.isVisible = true } if (::configurationListView.isInitialized) { configurationListView.requestFocus() } } fun checkOrderMenu() { if (parent?.select == true) return val pf = parentFragment as? ToolbarFragment ?: return val menu = pf.toolbar.menu val origin = menu.findItem(R.id.action_order_origin) val byName = menu.findItem(R.id.action_order_by_name) val byDelay = menu.findItem(R.id.action_order_by_delay) when (proxyGroup.order) { GroupOrder.ORIGIN -> { origin.isChecked = true } GroupOrder.BY_NAME -> { byName.isChecked = true } GroupOrder.BY_DELAY -> { byDelay.isChecked = true } } fun updateTo(order: Int) { if (proxyGroup.order == order) return runOnDefaultDispatcher { proxyGroup.order = order GroupManager.updateGroup(proxyGroup, reconfigureUpdater = false) } } origin.setOnMenuItemClickListener { it.isChecked = true updateTo(GroupOrder.ORIGIN) true } byName.setOnMenuItemClickListener { it.isChecked = true updateTo(GroupOrder.BY_NAME) true } byDelay.setOnMenuItemClickListener { it.isChecked = true updateTo(GroupOrder.BY_DELAY) true } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val parent = parent ?: return if (!::proxyGroup.isInitialized) return configurationListView = view.findViewById(R.id.configuration_list) ViewCompat.setOnApplyWindowInsetsListener(configurationListView) { 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), bottom = bars.bottom + dp2px(64), ) insets } layoutManager = FixedLinearLayoutManager(configurationListView) configurationListView.layoutManager = layoutManager adapter = ConfigurationAdapter() ProfileManager.addListener(adapter) GroupManager.addListener(adapter) configurationListView.adapter = adapter configurationListView.setItemViewCacheSize(20) if (!parent.select) { undoManager = UndoSnackbarManager(activity as MainActivity, adapter) } if (!parent.select && proxyGroup.type == GroupType.BASIC) { ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START ) { override fun getSwipeDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ): Int { return 0 } override fun getDragDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) = if (isEnabled && !actionButtonPressed) super.getDragDirs( recyclerView, viewHolder ) else 0 override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { adapter.move( viewHolder.adapterPosition, target.adapterPosition ) return true } override fun clearView( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) { super.clearView(recyclerView, viewHolder) adapter.commitMove() } }).attachToRecyclerView(configurationListView) } } override fun onDestroy() { if (::adapter.isInitialized) { ProfileManager.removeListener(adapter) GroupManager.removeListener(adapter) } super.onDestroy() if (!::undoManager.isInitialized) return undoManager.flush() } inner class ConfigurationAdapter : RecyclerView.Adapter(), ProfileManager.Listener, GroupManager.Listener, UndoSnackbarManager.Interface { init { setHasStableIds(true) } var configurationIdList: MutableList = mutableListOf() val configurationList = HashMap() val pendingDeletedIds = java.util.concurrent.ConcurrentHashMap.newKeySet() private fun getItem(profileId: Long): ProxyEntity? { var profile = configurationList[profileId] if (profile == null) { profile = ProfileManager.getProfile(profileId) if (profile != null) { configurationList[profileId] = profile } } return profile } private fun getItemAt(index: Int) = getItem(configurationIdList[index]) override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, ): ConfigurationHolder { return ConfigurationHolder( LayoutInflater.from(parent.context) .inflate(R.layout.layout_profile, parent, false) ) } override fun getItemId(position: Int): Long { return configurationIdList[position] } override fun onBindViewHolder(holder: ConfigurationHolder, position: Int) { try { getItemAt(position)?.let { holder.bind(it) } } catch (ignored: NullPointerException) { // when group deleted } } override fun getItemCount(): Int { return configurationIdList.size } var activeSelectionId: Long = -1 fun refreshSelection() { val newId = DataStore.selectedProxy if (activeSelectionId == newId) return val oldId = activeSelectionId activeSelectionId = newId listOf(oldId, newId).forEach { id -> val index = configurationIdList.indexOf(id) if (index != -1) notifyItemChanged(index, "PAYLOAD_SELECTION_CHANGE") } } override fun onBindViewHolder( holder: ConfigurationHolder, position: Int, payloads: MutableList ) { if (payloads.contains("PAYLOAD_SELECTION_CHANGE")) { val entityId = configurationIdList[position] val isSelected = (entityId == activeSelectionId) val isStarted = isSelected && SagerNet.started && DataStore.startedProfile == entityId holder.selectedView.visibility = if (isSelected) View.VISIBLE else View.INVISIBLE holder.deleteButton.isEnabled = !isStarted } else { super.onBindViewHolder(holder, position, payloads) } } private val updated = HashSet() fun filter(name: String) { if (name.isEmpty()) { reloadProfiles() return } configurationIdList.clear() val lower = name.lowercase() configurationIdList.addAll(configurationList.filter { it.value.displayName().lowercase().contains(lower) || it.value.displayType().lowercase().contains(lower) || it.value.displayAddress().lowercase().contains(lower) }.keys) notifyDataSetChanged() } fun move(from: Int, to: Int) { val first = getItemAt(from) ?: return var previousOrder = first.userOrder val (step, range) = if (from < to) Pair(1, from until to) else Pair( -1, to + 1 downTo from ) for (i in range) { val next = getItemAt(i + step) ?: return val order = next.userOrder next.userOrder = previousOrder previousOrder = order configurationIdList[i] = next.id updated.add(next) } first.userOrder = previousOrder configurationIdList[to] = first.id updated.add(first) notifyItemMoved(from, to) } fun commitMove() = runOnDefaultDispatcher { updated.forEach { SagerDatabase.proxyDao.updateProxy(it) } updated.clear() } fun remove(pos: Int) { if (pos < 0) return configurationIdList.removeAt(pos) notifyItemRemoved(pos) } override fun undo(actions: List>) { for ((index, item) in actions) { pendingDeletedIds.remove(item.id) configurationListView.post { if (!configurationIdList.contains(item.id)) { configurationList[item.id] = item val safeIndex = index.coerceIn(0, configurationIdList.size) configurationIdList.add(safeIndex, item.id) notifyItemInserted(safeIndex) } } } } override fun commit(actions: List>) { val profiles = actions.map { it.second } runOnDefaultDispatcher { for (entity in profiles) { ProfileManager.deleteProfile(entity.groupId, entity.id) } } } override suspend fun onAdd(profile: ProxyEntity) { if (profile.groupId != proxyGroup.id) return configurationListView.post { if (::undoManager.isInitialized) { undoManager.flush() } val pos = itemCount configurationList[profile.id] = profile configurationIdList.add(profile.id) notifyItemInserted(pos) } } override suspend fun onUpdated(profile: ProxyEntity) { if (profile.groupId != proxyGroup.id) return val index = configurationIdList.indexOf(profile.id) if (index < 0) return configurationListView.post { if (::undoManager.isInitialized) { undoManager.flush() } configurationList[profile.id] = profile notifyItemChanged(index) } } override suspend fun onUpdated(profileId: Long, trafficStats: TrafficStats) { val index = configurationIdList.indexOf(profileId) if (index != -1) { val profile = configurationList[profileId] if (profile != null) { profile.stats = trafficStats onMainDispatcher { val holder = configurationListView.findViewHolderForAdapterPosition(index) as? ConfigurationHolder if (holder != null && holder.entity.id == profileId) { holder.updateTraffic(profile) } } } } } override suspend fun onRemoved(groupId: Long, profileId: Long) { if (groupId != proxyGroup.id) return pendingDeletedIds.remove(profileId) configurationListView.post { val index = configurationIdList.indexOf(profileId) if (index >= 0) { configurationIdList.removeAt(index) configurationList.remove(profileId) notifyItemRemoved(index) } } } override suspend fun groupAdd(group: ProxyGroup) = Unit override suspend fun groupRemoved(groupId: Long) = Unit override suspend fun groupUpdated(group: ProxyGroup) { if (group.id != proxyGroup.id) return proxyGroup = group reloadProfiles() } override suspend fun groupUpdated(groupId: Long) { if (groupId != proxyGroup.id) return proxyGroup = SagerDatabase.groupDao.getById(groupId)!! reloadProfiles() } fun reloadProfiles() { val selectedItem = try { requirePrent().selectedItem } catch (ignored: IllegalStateException) { return } activeSelectionId = selectedItem?.id ?: DataStore.selectedProxy var newProfiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) newProfiles = newProfiles.filter { it.id !in pendingDeletedIds } when (proxyGroup.order) { GroupOrder.BY_NAME -> { newProfiles = newProfiles.sortedBy { it.displayName() } } GroupOrder.BY_DELAY -> { newProfiles = newProfiles.sortedBy { if (it.status == 1) it.ping else 114514 } } } configurationList.clear() configurationList.putAll(newProfiles.associateBy { it.id }) val newProfileIds = newProfiles.map { it.id } var selectedProfileIndex = -1 if (selected) { val selectedProxy = selectedItem?.id ?: DataStore.selectedProxy selectedProfileIndex = newProfileIds.indexOf(selectedProxy) } configurationListView.post { configurationIdList.clear() configurationIdList.addAll(newProfileIds) notifyDataSetChanged() if (selectedProfileIndex != -1 && !scrolled) { configurationListView.scrollTo(selectedProfileIndex, true) scrolled = true } else if (newProfiles.isNotEmpty() && !scrolled) { configurationListView.scrollTo(0, true) scrolled = true } } } } val profileAccess = Mutex() val reloadAccess = Mutex() inner class ConfigurationHolder(val view: View) : RecyclerView.ViewHolder(view), PopupMenu.OnMenuItemClickListener { lateinit var entity: ProxyEntity val profileName: TextView = view.findViewById(R.id.profile_name) val profileType: TextView = view.findViewById(R.id.profile_type) val profileAddress: TextView = view.findViewById(R.id.profile_address) val profileStatus: TextView = view.findViewById(R.id.profile_status) val trafficText: TextView = view.findViewById(R.id.traffic_text) val selectedView: LinearLayout = view.findViewById(R.id.selected_view) val editButton: ImageView = view.findViewById(R.id.edit) val shareLayout: LinearLayout = view.findViewById(R.id.share) val shareLayer: LinearLayout = view.findViewById(R.id.share_layer) val shareButton: ImageView = view.findViewById(R.id.shareIcon) val deleteButton: ImageView = view.findViewById(R.id.deleteIcon) fun bind(proxyEntity: ProxyEntity) { val parent = parent ?: return entity = proxyEntity if (parent.select) { view.setOnClickListener { (requireActivity() as SelectCallback).returnProfile(proxyEntity.id) } } else { val pa = activity as MainActivity view.setOnClickListener { runOnDefaultDispatcher { var update: Boolean profileAccess.withLock { update = DataStore.selectedProxy != proxyEntity.id DataStore.selectedProxy = proxyEntity.id } if (update) { onMainDispatcher { adapter.refreshSelection() } if (pa.state.canStop && reloadAccess.tryLock()) { SagerNet.reloadService() reloadAccess.unlock() } } else if (SagerNet.isTv) { if (SagerNet.started) { SagerNet.stopService() } else { SagerNet.startService() } } } } } profileName.text = proxyEntity.displayName() profileType.text = proxyEntity.displayType() var rx = proxyEntity.rx var tx = proxyEntity.tx val stats = proxyEntity.stats if (stats != null) { rx += stats.rxTotal tx += stats.txTotal } val showTraffic = rx + tx != 0L trafficText.isVisible = showTraffic if (showTraffic) { trafficText.text = view.context.getString( R.string.traffic, FormatFileSizeCompat.formatFileSize(view.context, tx, DataStore.useIECUnit), FormatFileSizeCompat.formatFileSize(view.context, rx, DataStore.useIECUnit) ) } var address = proxyEntity.displayAddress() if (proxyEntity.requireBean().name.isEmpty() || !parent.alwaysShowAddress) { address = "" } profileAddress.text = address (trafficText.parent as View).isGone = (!showTraffic || proxyEntity.status <= 0) && address.isEmpty() if (proxyEntity.status <= 0) { if (showTraffic) { profileStatus.text = trafficText.text profileStatus.setTextColor(requireContext().getColorAttr(android.R.attr.textColorSecondary)) trafficText.text = "" } else { profileStatus.text = "" } } else if (proxyEntity.status == 1) { profileStatus.text = getString(R.string.available, proxyEntity.ping) profileStatus.setTextColor(requireContext().getColour(R.color.material_green_500)) } else { profileStatus.setTextColor(requireContext().getColour(R.color.material_red_500)) if (proxyEntity.status == 2) { profileStatus.text = proxyEntity.error } } if (proxyEntity.status == 3) { profileStatus.setText(R.string.unavailable) profileStatus.setOnClickListener { alert(proxyEntity.error ?: "").show() } } else { profileStatus.setOnClickListener(null) } editButton.setOnClickListener { proxyEntity.settingIntent(it.context, proxyGroup.type == GroupType.SUBSCRIPTION)?.let { editProfileLauncher.launch(it) } } deleteButton.setOnClickListener { view -> view.post { adapter.let { val index = it.configurationIdList.indexOf(proxyEntity.id) if (index >= 0) { it.remove(index) it.pendingDeletedIds.add(proxyEntity.id) undoManager.remove(index to proxyEntity) } } } } // suppress ItemTouchHelper drag while a row button is held, to avoid conflict with parent item's long-press deleteButton.suppressDragWhilePressed { actionButtonPressed = it } editButton.suppressDragWhilePressed { actionButtonPressed = it } shareLayout.suppressDragWhilePressed { actionButtonPressed = it } editButton.isGone = parent.select deleteButton.isGone = parent.select shareButton.isGone = parent.select runOnDefaultDispatcher { val selected = (parent.selectedItem?.id ?: DataStore.selectedProxy) == proxyEntity.id val started = selected && SagerNet.started && DataStore.startedProfile == proxyEntity.id onMainDispatcher { deleteButton.isEnabled = !started selectedView.visibility = if (selected) View.VISIBLE else View.INVISIBLE } fun showShare(anchor: View) { val popup = PopupMenu(requireContext(), anchor) popup.menuInflater.inflate(R.menu.profile_share_menu, popup.menu) if (!proxyEntity.hasShareLink() && proxyEntity.wgBean == null) { popup.menu.removeItem(R.id.action_qr) popup.menu.removeItem(R.id.action_clipboard) } if (showBackup && proxyEntity.canExportBackup()) { popup.menu.findItem(R.id.action_export_backup).isVisible = true } popup.setOnMenuItemClickListener(this@ConfigurationHolder) popup.show() } if (!parent.select) { val isInsecure = DataStore.profileSecurityAdvisory && proxyEntity.requireBean().isInsecure onMainDispatcher { if (isInsecure) { shareLayer.setBackgroundColor(Color.RED) shareButton.setImageResource(R.drawable.ic_baseline_warning_24) shareButton.setColorFilter(Color.WHITE) } else { shareLayer.setBackgroundColor(Color.TRANSPARENT) shareButton.setImageResource(R.drawable.ic_social_share) shareButton.setColorFilter(Color.GRAY) } shareButton.isVisible = true if (isInsecure) { shareLayout.setOnClickListener { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.insecure_warn) .setMessage(R.string.insecure_warning_detail) .setPositiveButton(android.R.string.ok) { _, _ -> showShare(it) } .show() } } else { shareLayout.setOnClickListener { showShare(it) } } } } } } fun updateTraffic(proxyEntity: ProxyEntity) { val parent = parent ?: return if (!::entity.isInitialized) return entity.stats = proxyEntity.stats var rx = proxyEntity.rx var tx = proxyEntity.tx val stats = proxyEntity.stats if (stats != null) { rx += stats.rxTotal tx += stats.txTotal } val showTraffic = rx + tx != 0L trafficText.isVisible = showTraffic if (showTraffic) { trafficText.text = view.context.getString( R.string.traffic, FormatFileSizeCompat.formatFileSize(view.context, tx, DataStore.useIECUnit), FormatFileSizeCompat.formatFileSize(view.context, rx, DataStore.useIECUnit) ) } var address = proxyEntity.displayAddress() if (proxyEntity.requireBean().name.isEmpty() || !parent.alwaysShowAddress) { address = "" } (trafficText.parent as View).isGone = (!showTraffic || proxyEntity.status <= 0) && address.isEmpty() if (proxyEntity.status <= 0) { if (showTraffic) { profileStatus.text = trafficText.text profileStatus.setTextColor(requireContext().getColorAttr(android.R.attr.textColorSecondary)) trafficText.text = "" } else { profileStatus.text = "" } } } fun showCode(link: String) { QRCodeDialog(link).showAllowingStateLoss(parentFragmentManager) } fun export(link: String) { val success = SagerNet.trySetPrimaryClip(link) (activity as MainActivity).snackbar(if (success) R.string.action_export_msg else R.string.action_export_err) .show() } override fun onMenuItemClick(item: MenuItem): Boolean { try { when (item.itemId) { R.id.action_qr -> { if (entity.wgBean != null) { entity.wgBean?.toConf()?.let { showCode(it) } } else { entity.toLink()?.let { showCode(it) } } } R.id.action_clipboard -> { if (entity.wgBean != null) { entity.wgBean?.toConf()?.let { export(it) } } else { entity.toLink()?.let { export(it) } } } R.id.action_export_config_clipboard -> export(entity.exportConfig().first) R.id.action_export_config_file -> { val cfg = entity.exportConfig() DataStore.serverConfig = cfg.first startFilesForResult( (parentFragment as ConfigurationFragment).exportConfig, cfg.second ) } R.id.action_export_backup_clipboard -> export(entity.requireBean().exportBackup()) } } catch (e: Exception) { Logs.w(e) (activity as MainActivity).snackbar(e.readableMessage).show() return true } return true } } private val editProfileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { if (DataStore.currentProfile == DataStore.editingId) { needReload() } } } } private val exportConfig = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> if (data != null) { runOnDefaultDispatcher { try { (requireActivity() as MainActivity).contentResolver.openOutputStream(data)!! .bufferedWriter() .use { it.write(DataStore.serverConfig) } onMainDispatcher { snackbar(getString(R.string.action_export_msg)).show() } } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } } }