owenclave / app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.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.content.Intent import android.os.Bundle import android.text.format.DateUtils import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.Toolbar import androidx.core.view.* import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProxyGroup import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.databinding.LayoutGroupItemBinding import io.nekohasekai.sagernet.fmt.exportBackup import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.utils.FormatFileSizeCompat import io.nekohasekai.sagernet.widget.QRCodeDialog import io.nekohasekai.sagernet.widget.UndoSnackbarManager import java.util.* class GroupFragment : ToolbarFragment(R.layout.layout_group), Toolbar.OnMenuItemClickListener { lateinit var groupListView: RecyclerView lateinit var layoutManager: LinearLayoutManager lateinit var groupAdapter: GroupAdapter lateinit var undoManager: UndoSnackbarManager val showBackup = DataStore.experimentalFlagsProperties.getBooleanProperty("enableProfileBackup") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) toolbar.setTitle(R.string.menu_group) toolbar.inflateMenu(R.menu.add_group_menu) toolbar.setOnMenuItemClickListener(this) groupListView = view.findViewById(R.id.group_list) ViewCompat.setOnApplyWindowInsetsListener(groupListView) { 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(groupListView) groupListView.layoutManager = layoutManager groupAdapter = GroupAdapter() GroupManager.addListener(groupAdapter) groupListView.adapter = groupAdapter undoManager = UndoSnackbarManager(requireActivity() as ThemedActivity, groupAdapter) ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START ) { override fun getSwipeDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { val proxyGroup = (viewHolder as GroupHolder).proxyGroup if (proxyGroup.ungrouped || proxyGroup.id in GroupUpdater.updating) { return 0 } return super.getSwipeDirs(recyclerView, viewHolder) } override fun getDragDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { val proxyGroup = (viewHolder as GroupHolder).proxyGroup if (proxyGroup.ungrouped || proxyGroup.id in GroupUpdater.updating) { return 0 } return super.getDragDirs(recyclerView, viewHolder) } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val index = viewHolder.adapterPosition groupAdapter.remove(index) undoManager.remove(index to (viewHolder as GroupHolder).proxyGroup) } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { groupAdapter.move(viewHolder.adapterPosition, target.adapterPosition) return true } override fun clearView( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ) { super.clearView(recyclerView, viewHolder) groupAdapter.commitMove() } }).attachToRecyclerView(groupListView) (requireActivity() as? MainActivity)?.onBackPressedCallback?.isEnabled = true } override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_new_group -> { startActivity(Intent(context, GroupSettingsActivity::class.java)) } R.id.action_update_all_subscriptions -> { val connected = SagerNet.started && DataStore.startedProfile > 0 MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.update_all_subscriptions) .setPositiveButton(android.R.string.ok) { _, _ -> SagerDatabase.groupDao.allGroups() .filter { it.type == GroupType.SUBSCRIPTION } .filter { if (connected) true else !it.subscription!!.updateWhenConnectedOnly } .forEach { GroupUpdater.startUpdate(it, byUser = false) // Do not display changelog or error message } } .setNegativeButton(android.R.string.cancel, null) .show() } } return true } private lateinit var selectedGroup: ProxyGroup private val exportProfiles = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> if (data != null) { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) val links = profiles.mapNotNull { try { it.toLink() } catch (_: Exception) { null } }.joinToString("\n") try { (requireActivity() as MainActivity).contentResolver.openOutputStream( data )!!.bufferedWriter().use { it.write(links) } onMainDispatcher { snackbar(getString(R.string.action_export_msg)).show() } } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } } private val exportBackupOfAllProfiles = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> if (data != null) { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) val links = profiles.mapNotNull { if (it.canExportBackup()) { it.requireBean().exportBackup() } else null }.joinToString("\n") try { (requireActivity() as MainActivity).contentResolver.openOutputStream( data )!!.bufferedWriter().use { it.write(links) } onMainDispatcher { snackbar(getString(R.string.action_export_msg)).show() } } catch (e: Exception) { Logs.w(e) onMainDispatcher { snackbar(e.readableMessage).show() } } } } } inner class GroupAdapter : RecyclerView.Adapter(), GroupManager.Listener, UndoSnackbarManager.Interface { val groupList = ArrayList() fun reload() { val groups = SagerDatabase.groupDao.allGroups().toMutableList() groups.find { it.ungrouped }?.let { if (SagerDatabase.proxyDao.countByGroup(it.id) == 0L) { groups.remove(it) } } groupList.clear() groupList.addAll(groups) groupListView.post { notifyDataSetChanged() } } init { setHasStableIds(true) runOnDefaultDispatcher { reload() } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupHolder { return GroupHolder(LayoutGroupItemBinding.inflate(layoutInflater, parent, false)) } override fun onBindViewHolder(holder: GroupHolder, position: Int) { holder.bind(groupList[position]) } override fun getItemCount(): Int { return groupList.size } override fun getItemId(position: Int): Long { return groupList[position].id } private val updated = HashSet() fun move(from: Int, to: Int) { val first = groupList[from] 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 = groupList[i + step] val order = next.userOrder next.userOrder = previousOrder previousOrder = order groupList[i] = next updated.add(next) } first.userOrder = previousOrder groupList[to] = first updated.add(first) notifyItemMoved(from, to) } fun commitMove() = runOnDefaultDispatcher { updated.forEach { SagerDatabase.groupDao.updateGroup(it) } updated.clear() } fun remove(index: Int) { groupList.removeAt(index) notifyItemRemoved(index) } override fun undo(actions: List>) { for ((index, item) in actions) { groupList.add(index, item) notifyItemInserted(index) } } override fun commit(actions: List>) { val groups = actions.map { it.second } runOnDefaultDispatcher { GroupManager.deleteGroup(groups) reload() } } override suspend fun groupAdd(group: ProxyGroup) { groupList.add(group) onMainDispatcher { undoManager.flush() notifyItemInserted(groupList.size - 1) /*if (group.type == GroupType.SUBSCRIPTION) { GroupUpdater.startUpdate(group, true) }*/ } } override suspend fun groupRemoved(groupId: Long) { val index = groupList.indexOfFirst { it.id == groupId } if (index == -1) return onMainDispatcher { undoManager.flush() groupList.removeAt(index) notifyItemRemoved(index) } } override suspend fun groupUpdated(group: ProxyGroup) { val index = groupList.indexOfFirst { it.id == group.id } if (index == -1) { reload() return } groupList[index] = group onMainDispatcher { undoManager.flush() notifyItemChanged(index) } } override suspend fun groupUpdated(groupId: Long) { val index = groupList.indexOfFirst { it.id == groupId } if (index == -1) { reload() return } onMainDispatcher { notifyItemChanged(index) } } } override fun onDestroy() { if (::groupAdapter.isInitialized) { GroupManager.removeListener(groupAdapter) } super.onDestroy() if (!::undoManager.isInitialized) return undoManager.flush() } inner class GroupHolder(binding: LayoutGroupItemBinding) : RecyclerView.ViewHolder(binding.root), PopupMenu.OnMenuItemClickListener { lateinit var proxyGroup: ProxyGroup val groupName = binding.groupName val groupStatus = binding.groupStatus val groupTraffic = binding.groupTraffic val editButton = binding.edit val optionsButton = binding.options val updateButton = binding.groupUpdate val subscriptionUpdateProgress = binding.subscriptionUpdateProgress override fun onMenuItemClick(item: MenuItem): Boolean { fun showCode(link: String) { QRCodeDialog(link).showAllowingStateLoss(parentFragmentManager) } when (item.itemId) { R.id.action_subscription_link_qr -> { showCode(proxyGroup.subscription!!.link!!) } R.id.action_subscription_link_clipboard -> { val link = proxyGroup.subscription!!.link!! runOnDefaultDispatcher { onMainDispatcher { SagerNet.trySetPrimaryClip(link) snackbar(R.string.action_export_msg).show() } } } R.id.action_clipboard -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) val links = profiles.mapNotNull { try { it.toLink() } catch (_: Exception) { null } }.joinToString("\n") onMainDispatcher { SagerNet.trySetPrimaryClip(links) snackbar(R.string.action_export_msg).show() } } } R.id.action_file -> { startFilesForResult(exportProfiles, "profiles_${proxyGroup.displayName()}.txt") } R.id.action_export_backup_of_all_profiles_clipboard -> { runOnDefaultDispatcher { val profiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) val links = profiles.mapNotNull { if (it.canExportBackup()) { it.requireBean().exportBackup() } else null }.joinToString("\n") onMainDispatcher { SagerNet.trySetPrimaryClip(links) snackbar(R.string.action_export_msg).show() } } } R.id.action_export_backup_of_all_profiles_file -> { startFilesForResult(exportBackupOfAllProfiles, "profiles_${proxyGroup.displayName()}_backup.txt") } R.id.action_clear -> { MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) .setMessage(R.string.clear_profiles_message) .setPositiveButton(android.R.string.ok) { _, _ -> runOnDefaultDispatcher { GroupManager.clearGroup(proxyGroup.id) } } .setNegativeButton(android.R.string.cancel, null) .show() } } return true } fun bind(group: ProxyGroup) { proxyGroup = group itemView.setOnClickListener(null) editButton.isGone = group.ungrouped updateButton.isVisible = group.type == GroupType.SUBSCRIPTION optionsButton.isGone = false groupName.text = group.displayName() editButton.setOnClickListener { startActivity(Intent(it.context, GroupSettingsActivity::class.java).apply { putExtra(GroupSettingsActivity.EXTRA_GROUP_ID, group.id) }) } updateButton.setOnClickListener { GroupUpdater.startUpdate(group, true) } optionsButton.setOnClickListener { selectedGroup = group val popup = PopupMenu(requireContext(), it) popup.menuInflater.inflate(R.menu.group_action_menu, popup.menu) if (group.type != GroupType.SUBSCRIPTION) { popup.menu.findItem(R.id.action_share).subMenu?.removeItem(R.id.action_export_backup) popup.menu.findItem(R.id.action_share).subMenu?.removeItem(R.id.action_subscription_link) } if (showBackup) { popup.menu.findItem(R.id.action_export_backup_of_all_profiles).isVisible = true } popup.setOnMenuItemClickListener(this) popup.show() } if (group.id in GroupUpdater.updating) { (groupName.parent as LinearLayout).apply { setPadding(paddingLeft, dp2px(11), paddingRight, paddingBottom) } subscriptionUpdateProgress.isVisible = true if (!GroupUpdater.progress.containsKey(group.id)) { subscriptionUpdateProgress.isIndeterminate = true } else { subscriptionUpdateProgress.isIndeterminate = false val progress = GroupUpdater.progress[group.id]!! subscriptionUpdateProgress.max = progress.max subscriptionUpdateProgress.progress = progress.progress } updateButton.isInvisible = true editButton.isGone = true optionsButton.isGone = true } else { (groupName.parent as LinearLayout).apply { setPadding(paddingLeft, dp2px(15), paddingRight, paddingBottom) } subscriptionUpdateProgress.isVisible = false updateButton.isVisible = group.type == GroupType.SUBSCRIPTION editButton.isGone = group.ungrouped } if (group.type == GroupType.SUBSCRIPTION) { val subscription = group.subscription!! val text = mutableListOf() if (subscription.bytesUsed > 0L || subscription.bytesRemaining > 0L) { text.add(if (subscription.bytesRemaining > 0L) { getString( R.string.subscription_traffic, FormatFileSizeCompat.formatFileSize( context, subscription.bytesUsed, DataStore.useIECUnit ), FormatFileSizeCompat.formatFileSize( context, subscription.bytesRemaining, DataStore.useIECUnit ) ) } else { getString( R.string.subscription_used, FormatFileSizeCompat.formatFileSize( context, subscription.bytesUsed, DataStore.useIECUnit ) ) }) } if (subscription.expiryDate > 0L) { text.add(getString( R.string.subscription_expire, DateUtils.getRelativeTimeSpanString(context, subscription.expiryDate * 1000) // hack for Chinese, "1月1日" -> "1 月 1 日","上午0:00" -> 上午 0:00" .replace("^([1-9]|1[0-2])月([1-9]|1[0-9]|2[0-9]|3[0-1])日+".toRegex(), "$1 月 $2 日") .replace("^上午(([1-9]|1[0-2]):([0-5][0-9]))+".toRegex(), "上午 $1") .replace("^下午(([1-9]|1[0-2]):([0-5][0-9]))+".toRegex(), "下午 $1") )) } if (text.isNotEmpty()) { groupTraffic.isVisible = true groupTraffic.text = text.joinToString("\n") groupStatus.setPadding(0) if (proxyGroup.id !in GroupUpdater.updating && subscription.bytesRemaining > 0L) { subscriptionUpdateProgress.apply { isVisible = true setProgressCompat( ((subscription.bytesUsed.toDouble() / (subscription.bytesUsed + subscription.bytesRemaining).toDouble()) * 100).toInt(), true ) } } } else { groupTraffic.isVisible = false groupStatus.setPadding(0, 0, 0, dp2px(4)) } } else { groupTraffic.isVisible = false groupStatus.setPadding(0, 0, 0, dp2px(4)) } runOnDefaultDispatcher { val size = SagerDatabase.proxyDao.countByGroup(group.id) onMainDispatcher { try { when (group.type) { GroupType.BASIC -> { if (size == 0L) { groupStatus.setText(R.string.group_status_empty) } else { groupStatus.text = (requireActivity() as MainActivity).resources.getQuantityString(R.plurals.group_status_proxies, size.toInt(), size) } } GroupType.SUBSCRIPTION -> { groupStatus.text = when { size == 0L -> getString(R.string.group_status_empty_subscription) group.subscription!!.lastUpdated <= 0L -> context!!.resources.getQuantityString(R.plurals.group_status_proxies, size.toInt(), size) else -> (requireActivity() as MainActivity).resources.getQuantityString(R.plurals.group_status_proxies_subscription, size.toInt(), size, DateUtils.getRelativeTimeSpanString(context, group.subscription!!.lastUpdated * 1000) // hack for Chinese, "1月1日" -> "1 月 1 日","上午0:00" -> 上午 0:00" .replace("^([1-9]|1[0-2])月([1-9]|1[0-9]|2[0-9]|3[0-1])日+".toRegex(), "$1 月 $2 日") .replace("^上午(([1-9]|1[0-2]):([0-5][0-9]))+".toRegex(), "上午 $1") .replace("^下午(([1-9]|1[0-2]):([0-5][0-9]))+".toRegex(), "下午 $1") ) } } } } catch (e: IllegalStateException) { Logs.e(e.readableMessage) } } } } } }