Browse Source

Improve profile list

世界 4 years ago
parent
commit
77d82213af
28 changed files with 583 additions and 369 deletions
  1. 3 1
      app/proguard-rules.pro
  2. 1 1
      app/src/main/AndroidManifest.xml
  3. 6 2
      app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt
  4. 2 2
      app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt
  5. 2 2
      app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt
  6. 4 4
      app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
  7. 2 2
      app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
  8. 90 0
      app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt
  9. 30 5
      app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
  10. 2 2
      app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
  11. 2 2
      app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt
  12. 0 36
      app/src/main/java/io/nekohasekai/sagernet/ktx/Events.kt
  13. 349 11
      app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt
  14. 2 2
      app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt
  15. 3 3
      app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt
  16. 0 70
      app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationAdapter.kt
  17. 0 99
      app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationHolder.kt
  18. 0 39
      app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupFragment.kt
  19. 0 43
      app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupPagerAdapter.kt
  20. 11 16
      app/src/main/java/io/nekohasekai/sagernet/ui/settings/ProfileSettingsActivity.kt
  21. 0 9
      app/src/main/java/io/nekohasekai/sagernet/ui/settings/SocksSettingsActivity.kt
  22. 46 0
      app/src/main/java/io/nekohasekai/sagernet/widget/UndoSnackbarManager.kt
  23. 14 8
      app/src/main/res/layout/app_bar_main.xml
  24. 1 0
      app/src/main/res/layout/configurtion_list_main.xml
  25. 1 1
      app/src/main/res/layout/layout_profile.xml
  26. 10 9
      app/src/main/res/menu/profile_manager_menu.xml
  27. 1 0
      app/src/main/res/values/strings.xml
  28. 1 0
      gradle.properties

+ 3 - 1
app/proguard-rules.pro

@@ -21,4 +21,6 @@
 #-renamesourcefileattribute SourceFile
 
 -repackageclasses ''
--keepattributes SourceFile,LineNumberTable
+-allowaccessmodification
+-printconfiguration
+-forceprocessing

+ 1 - 1
app/src/main/AndroidManifest.xml

@@ -23,7 +23,7 @@
         android:required="false" />
 
     <application
-        android:name=".SagerApp"
+        android:name=".SagerNet"
         android:allowBackup="false"
         android:icon="@mipmap/ic_launcher"
         android:label="@string/app_name"

+ 6 - 2
app/src/main/java/io/nekohasekai/sagernet/SagerApp.kt → app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt

@@ -17,14 +17,16 @@ import androidx.core.content.ContextCompat
 import androidx.core.content.getSystemService
 import io.nekohasekai.sagernet.bg.SagerConnection
 import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.ProxyEntity
+import io.nekohasekai.sagernet.database.SagerDatabase
 import io.nekohasekai.sagernet.ui.MainActivity
 import io.nekohasekai.sagernet.utils.DeviceStorageApp
 import me.weishu.reflection.Reflection
 
-class SagerApp : Application() {
+class SagerNet : Application() {
 
     companion object {
-        lateinit var application: SagerApp
+        lateinit var application: SagerNet
         val deviceStorage by lazy {
             if (Build.VERSION.SDK_INT < 24) application else DeviceStorageApp(application)
         }
@@ -70,6 +72,8 @@ class SagerApp : Application() {
         fun stopService() =
             application.sendBroadcast(Intent(Action.CLOSE).setPackage(application.packageName))
 
+
+
     }
 
     override fun attachBaseContext(base: Context) {

+ 2 - 2
app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt

@@ -7,7 +7,7 @@ import android.system.Os
 import android.system.OsConstants
 import android.util.Log
 import androidx.annotation.MainThread
-import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.SagerNet
 import io.nekohasekai.sagernet.ktx.Logs
 import io.nekohasekai.sagernet.utils.Commandline
 import kotlinx.coroutines.*
@@ -34,7 +34,7 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C
         }    // ignore
 
         fun start() {
-            process = ProcessBuilder(cmd).directory(SagerApp.deviceStorage.noBackupFilesDir).start()
+            process = ProcessBuilder(cmd).directory(SagerNet.deviceStorage.noBackupFilesDir).start()
         }
 
         suspend fun looper(onRestartCallback: (suspend () -> Unit)?) {

+ 2 - 2
app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt

@@ -16,7 +16,7 @@ import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
 import com.github.shadowsocks.aidl.TrafficStats
 import io.nekohasekai.sagernet.Action
 import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.SagerNet
 
 /**
  * User can customize visibility of notification since Android 8.
@@ -60,7 +60,7 @@ class ServiceNotification(
         .setColor(ContextCompat.getColor(service, R.color.material_primary_500))
         .setTicker(service.getString(R.string.forward_success))
         .setContentTitle(profileName)
-        .setContentIntent(SagerApp.configureIntent(service))
+        .setContentIntent(SagerNet.configureIntent(service))
         .setSmallIcon(R.drawable.ic_service_active)
         .setCategory(NotificationCompat.CATEGORY_SERVICE)
         .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN)

+ 4 - 4
app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt

@@ -10,7 +10,7 @@ import android.system.ErrnoException
 import android.system.Os
 import io.nekohasekai.sagernet.Key
 import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.SagerNet
 import io.nekohasekai.sagernet.database.DataStore
 import io.nekohasekai.sagernet.ui.VpnRequestActivity
 import io.nekohasekai.sagernet.utils.Subnet
@@ -242,7 +242,7 @@ class VpnService : BaseVpnService(), BaseService.Interface {
     private suspend fun startVpn(): FileDescriptor {
         val profile = data.proxy!!.profile
         val builder = Builder()
-            .setConfigureIntent(SagerApp.configureIntent(this))
+            .setConfigureIntent(SagerNet.configureIntent(this))
             .setSession(profile.displayName())
             .setMtu(VPN_MTU)
             .addAddress(PRIVATE_VLAN4_CLIENT, 30)
@@ -309,7 +309,7 @@ class VpnService : BaseVpnService(), BaseService.Interface {
                 "--tunmtu",
                 VPN_MTU.toString(),
                 "--sock-path",
-                File(SagerApp.deviceStorage.noBackupFilesDir, "sock_path").canonicalPath,
+                File(SagerNet.deviceStorage.noBackupFilesDir, "sock_path").canonicalPath,
                 "--loglevel", "debug")
         if (DataStore.ipv6Route) {
             cmd += "--netif-ip6addr"
@@ -334,7 +334,7 @@ class VpnService : BaseVpnService(), BaseService.Interface {
 
     private suspend fun sendFd(fd: FileDescriptor) {
         var tries = 0
-        val path = File(SagerApp.deviceStorage.noBackupFilesDir, "sock_path").canonicalPath
+        val path = File(SagerNet.deviceStorage.noBackupFilesDir, "sock_path").canonicalPath
         while (true) try {
             delay(50L shl tries)
             LocalSocket().use { localSocket ->

+ 2 - 2
app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt

@@ -2,7 +2,7 @@ package io.nekohasekai.sagernet.database
 
 import android.os.Build
 import io.nekohasekai.sagernet.Key
-import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.SagerNet
 import io.nekohasekai.sagernet.database.preference.PublicDatabase
 import io.nekohasekai.sagernet.database.preference.RoomPreferenceDataStore
 import io.nekohasekai.sagernet.ktx.boolean
@@ -19,7 +19,7 @@ object DataStore {
 
     fun init() {
         if (Build.VERSION.SDK_INT >= 24) {
-            SagerApp.deviceStorage.moveDatabaseFrom(SagerApp.application, Key.DB_PUBLIC)
+            SagerNet.deviceStorage.moveDatabaseFrom(SagerNet.application, Key.DB_PUBLIC)
         }
 
         System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)

+ 90 - 0
app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt

@@ -0,0 +1,90 @@
+package io.nekohasekai.sagernet.database
+
+import android.database.sqlite.SQLiteCantOpenDatabaseException
+import io.nekohasekai.sagernet.fmt.AbstractBean
+import io.nekohasekai.sagernet.ktx.Logs
+import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
+import java.io.IOException
+import java.lang.ref.WeakReference
+import java.sql.SQLException
+import java.util.*
+
+object ProfileManager {
+
+    private val listeners = LinkedList<WeakReference<Listener>>()
+    private fun iterator(what: Listener.() -> Unit) {
+        synchronized(listeners) {
+            val iterator = listeners.iterator()
+            while (iterator.hasNext()) {
+                val listener = iterator.next().get()
+                if (listener == null) {
+                    iterator.remove()
+                    continue
+                }
+                what(listener)
+            }
+        }
+    }
+
+    fun addListener(listener: Listener) {
+        synchronized(listeners) {
+            listeners.add(WeakReference(listener))
+        }
+    }
+
+    interface Listener {
+        fun onAdd(profile: ProxyEntity)
+        fun onUpdated(profile: ProxyEntity)
+        fun onRemoved(groupId: Long, profileId: Long)
+        fun onCleared(groupId: Long)
+        fun reloadProfiles(groupId: Long)
+    }
+
+    fun createProfile(groupId: Long, bean: AbstractBean): ProxyEntity {
+        val profile = ProxyEntity(groupId = groupId).apply {
+            id = 0
+            putBean(bean)
+            userOrder = SagerDatabase.proxyDao.nextOrder(groupId) ?: 1
+        }
+        profile.id = SagerDatabase.proxyDao.addProxy(profile)
+        iterator { onAdd(profile) }
+        return profile
+    }
+
+    fun updateProfile(profile: ProxyEntity) {
+        SagerDatabase.proxyDao.updateProxy(profile)
+    }
+
+    fun deleteProfile(groupId: Long, profileId: Long) {
+        check(SagerDatabase.proxyDao.deleteById(profileId) > 0)
+        iterator { onRemoved(groupId, profileId) }
+        rearrange(groupId)
+    }
+
+    fun clear(groupId: Long) {
+        SagerDatabase.proxyDao.deleteAll(groupId)
+        iterator { onCleared(groupId) }
+    }
+
+    fun rearrange(groupId: Long) {
+        runOnDefaultDispatcher {
+            val entities = SagerDatabase.proxyDao.getByGroup(groupId)
+            for (index in entities.indices) {
+                entities[index].userOrder = (index + 1).toLong()
+            }
+            SagerDatabase.proxyDao.updateProxy(* entities.toTypedArray())
+        }
+    }
+
+    fun getProfile(profileId: Long): ProxyEntity? {
+        return try {
+            SagerDatabase.proxyDao.getById(profileId)
+        } catch (ex: SQLiteCantOpenDatabaseException) {
+            throw IOException(ex)
+        } catch (ex: SQLException) {
+            Logs.w(ex)
+            null
+        }
+    }
+
+}

+ 30 - 5
app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt

@@ -1,9 +1,13 @@
 package io.nekohasekai.sagernet.database
 
+import android.content.Context
+import android.content.Intent
 import androidx.room.*
 import io.nekohasekai.sagernet.fmt.AbstractBean
 import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
 import io.nekohasekai.sagernet.fmt.v2ray.VMessBean
+import io.nekohasekai.sagernet.ui.settings.ProfileSettingsActivity
+import io.nekohasekai.sagernet.ui.settings.SocksSettingsActivity
 
 @Entity(tableName = "proxy_entities", indices = [
     Index("groupId", name = "groupId")
@@ -12,7 +16,7 @@ class ProxyEntity(
     @PrimaryKey(autoGenerate = true)
     var id: Long = 0L,
     var groupId: Long,
-    var type: String,
+    var type: String = "",
     var userOrder: Long = 0L,
     var tx: Long = 0L,
     var rx: Long = 0L,
@@ -46,8 +50,14 @@ class ProxyEntity(
 
     fun putBean(bean: AbstractBean) {
         when (bean) {
-            is SOCKSBean -> socksBean = bean
-            is VMessBean -> vmessBean = bean
+            is SOCKSBean -> {
+                type = "socks"
+                socksBean = bean
+            }
+            is VMessBean -> {
+                type = "vmess"
+                vmessBean = bean
+            }
             else -> error("Undefined type $type")
         }
     }
@@ -55,12 +65,24 @@ class ProxyEntity(
     fun requireVMess() = requireBean() as VMessBean
     fun requireSOCKS() = requireBean() as SOCKSBean
 
+    fun settingIntent(ctx: Context): Intent {
+        return Intent(ctx, when (type) {
+            "socks" -> SocksSettingsActivity::class.java
+            else -> throw IllegalArgumentException()
+        }).apply {
+            putExtra(ProfileSettingsActivity.EXTRA_PROFILE_ID, id)
+        }
+    }
+
     @androidx.room.Dao
     interface Dao {
 
         @Query("SELECT * FROM proxy_entities WHERE groupId = :groupId ORDER BY userOrder")
         fun getByGroup(groupId: Long): List<ProxyEntity>
 
+        @Query("SELECT  MAX(userOrder) + 1 FROM proxy_entities WHERE groupId = :groupId")
+        fun nextOrder(groupId: Long): Long?
+
         @Query("SELECT * FROM proxy_entities WHERE id = :proxyId")
         fun getById(proxyId: Long): ProxyEntity?
 
@@ -68,10 +90,13 @@ class ProxyEntity(
         fun deleteById(proxyId: Long): Int
 
         @Insert
-        fun addProxy(proxy: ProxyEntity)
+        fun addProxy(proxy: ProxyEntity): Long
 
         @Update
-        fun updateProxy(proxy: ProxyEntity)
+        fun updateProxy(vararg proxy: ProxyEntity)
+
+        @Query("DELETE FROM proxy_entities WHERE groupId = :groupId")
+        fun deleteAll(groupId: Long): Int
 
     }
 }

+ 2 - 2
app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt

@@ -6,7 +6,7 @@ import androidx.room.RoomDatabase
 import androidx.room.TypeConverters
 import dev.matrix.roomigrant.GenerateRoomMigrations
 import io.nekohasekai.sagernet.Key
-import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.SagerNet
 import io.nekohasekai.sagernet.database.preference.KeyValuePair
 import io.nekohasekai.sagernet.fmt.KryoConverters
 import io.nekohasekai.sagernet.fmt.gson.GsonConverters
@@ -20,7 +20,7 @@ abstract class SagerDatabase : RoomDatabase() {
 
     companion object {
         private val instance by lazy {
-            Room.databaseBuilder(SagerApp.application, SagerDatabase::class.java, Key.DB_PROFILE)
+            Room.databaseBuilder(SagerNet.application, SagerDatabase::class.java, Key.DB_PROFILE)
                 .addMigrations(*SagerDatabase_Migrations.build())
                 .allowMainThreadQueries()
                 .enableMultiInstanceInvalidation()

+ 2 - 2
app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt

@@ -5,7 +5,7 @@ import androidx.room.Room
 import androidx.room.RoomDatabase
 import dev.matrix.roomigrant.GenerateRoomMigrations
 import io.nekohasekai.sagernet.Key
-import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.SagerNet
 import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.launch
 
@@ -14,7 +14,7 @@ import kotlinx.coroutines.launch
 abstract class PublicDatabase : RoomDatabase() {
     companion object {
         private val instance by lazy {
-            Room.databaseBuilder(SagerApp.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC)
+            Room.databaseBuilder(SagerNet.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC)
                 .addMigrations(*PublicDatabase_Migrations.build())
                 .allowMainThreadQueries()
                 .enableMultiInstanceInvalidation()

+ 0 - 36
app/src/main/java/io/nekohasekai/sagernet/ktx/Events.kt

@@ -1,36 +0,0 @@
-package io.nekohasekai.sagernet.ktx
-
-import java.lang.ref.SoftReference
-import java.util.*
-import java.util.concurrent.ConcurrentHashMap
-
-interface EventListener {
-    fun onEvent(eventId: Int, vararg args: Any)
-}
-
-const val EVENT_UPDATE_PROFILE = 0
-const val EVENT_UPDATE_GROUP = EVENT_UPDATE_PROFILE + 1
-
-private val listeners = ConcurrentHashMap<Int, LinkedList<SoftReference<EventListener>>>()
-
-fun registerListener(eventId: Int, listener: EventListener) {
-    listeners.getOrPut(eventId) { LinkedList() }.add(SoftReference(listener))
-}
-
-fun removeListener(eventId: Int, listener: EventListener) {
-    listeners[eventId]?.removeIf { it.get() == listener }
-}
-
-fun postNotification(eventId: Int, vararg args: Any) {
-    listeners[eventId]?.apply {
-        val iterator = iterator()
-        while (iterator.hasNext()) {
-            val listener = iterator.next().get()
-            if (listener == null) {
-                iterator.remove()
-                continue
-            }
-            listener.onEvent(eventId, * args)
-        }
-    }
-}

+ 349 - 11
app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt

@@ -1,24 +1,41 @@
 package io.nekohasekai.sagernet.ui
 
+import android.annotation.SuppressLint
+import android.content.Intent
 import android.os.Bundle
+import android.text.format.Formatter
+import android.view.LayoutInflater
 import android.view.MenuItem
 import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
 import androidx.appcompat.widget.PopupMenu
 import androidx.appcompat.widget.Toolbar
 import androidx.core.view.isGone
+import androidx.fragment.app.Fragment
+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.tabs.TabLayout
 import com.google.android.material.tabs.TabLayoutMediator
 import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.ktx.dp2px
-import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
-import io.nekohasekai.sagernet.ui.configuration.GroupPagerAdapter
+import io.nekohasekai.sagernet.bg.BaseService
+import io.nekohasekai.sagernet.database.*
+import io.nekohasekai.sagernet.ktx.*
+import io.nekohasekai.sagernet.ui.settings.ProfileSettingsActivity
+import io.nekohasekai.sagernet.ui.settings.SocksSettingsActivity
+import io.nekohasekai.sagernet.widget.UndoSnackbarManager
 
 class ConfigurationFragment : ToolbarFragment(R.layout.group_list_main),
     Toolbar.OnMenuItemClickListener,
     PopupMenu.OnMenuItemClickListener {
 
     lateinit var adapter: GroupPagerAdapter
+    lateinit var tabLayout: TabLayout
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
@@ -26,8 +43,8 @@ class ConfigurationFragment : ToolbarFragment(R.layout.group_list_main),
         toolbar.setOnMenuItemClickListener(this)
 
         val groupPager = view.findViewById<ViewPager2>(R.id.group_pager)
-        val tabLayout = view.findViewById<TabLayout>(R.id.group_tab)
-        adapter = GroupPagerAdapter(this)
+        tabLayout = view.findViewById(R.id.group_tab)
+        adapter = GroupPagerAdapter()
         groupPager.adapter = adapter
 
         TabLayoutMediator(tabLayout, groupPager) { tab, position ->
@@ -41,18 +58,339 @@ class ConfigurationFragment : ToolbarFragment(R.layout.group_list_main),
                 true
             }
         }.attach()
+    }
+
+    override fun onMenuItemClick(item: MenuItem): Boolean {
+        val selectGroup = adapter.groupList[tabLayout.selectedTabPosition]
+        when (item.itemId) {
+            R.id.action_new_socks -> {
+                startActivity(Intent(requireActivity(), SocksSettingsActivity::class.java).apply {
+                    putExtra(ProfileSettingsActivity.EXTRA_GROUP_ID, selectGroup.id)
+                })
+            }
+        }
+        return true
+    }
+
+    inner class GroupPagerAdapter : FragmentStateAdapter(this) {
+
+        var groupList: ArrayList<ProxyGroup> = ArrayList()
+
+        init {
+            runOnDefaultDispatcher {
+                groupList = ArrayList(SagerDatabase.groupDao.allGroups())
+                if (groupList.isEmpty()) {
+                    SagerDatabase.groupDao.createGroup(ProxyGroup(isDefault = true))
+                    groupList = ArrayList(SagerDatabase.groupDao.allGroups())
+                }
+                onMainDispatcher {
+                    notifyDataSetChanged()
+                    val hideTab = groupList.size == 1 && groupList[0].isDefault
+                    tabLayout.isGone = hideTab
+                    toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat()
+                }
+            }
+        }
 
-        runOnDefaultDispatcher {
-            adapter.reloadList {
-                tabLayout.isGone = it
-                toolbar.elevation = if (it) 0F else dp2px(4).toFloat()
+        override fun getItemCount(): Int {
+            return groupList.size
+        }
+
+        override fun createFragment(position: Int): Fragment {
+            return GroupFragment().apply {
+                proxyGroup = groupList[position]
             }
         }
 
+        override fun getItemId(position: Int): Long {
+            return groupList[position].id
+        }
+
+        override fun containsItem(itemId: Long): Boolean {
+            return groupList.any { it.id == itemId }
+        }
+
     }
 
-    override fun onMenuItemClick(item: MenuItem?): Boolean {
-        return true
+    class GroupFragment : Fragment() {
+
+        lateinit var proxyGroup: ProxyGroup
+
+        override fun onCreateView(
+            inflater: LayoutInflater,
+            container: ViewGroup?,
+            savedInstanceState: Bundle?,
+        ): View? {
+            return inflater.inflate(R.layout.configurtion_list_main, container, false)
+        }
+
+        lateinit var undoManager: UndoSnackbarManager<ProxyEntity>
+        lateinit var adapter: ConfigurationAdapter
+
+        private val isEnabled get() = (activity as MainActivity).state.let { it.canStop || it == BaseService.State.Stopped }
+        private fun isProfileEditable(id: Long) =
+            (activity as MainActivity).state == BaseService.State.Stopped || id != DataStore.selectedProxy
+
+        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+            if (!::proxyGroup.isInitialized) return
+
+            val configurationList = view.findViewById<RecyclerView>(R.id.configuration_list).apply {
+                layoutManager = when (proxyGroup.layout) {
+                    else -> LinearLayoutManager(view.context)
+                }
+            }
+            adapter = ConfigurationAdapter()
+            configurationList.adapter = adapter
+            undoManager =
+                UndoSnackbarManager(activity as MainActivity, adapter::undo, adapter::commit)
+            ItemTouchHelper(object :
+                ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN,
+                    ItemTouchHelper.START) {
+                override fun getSwipeDirs(
+                    recyclerView: RecyclerView,
+                    viewHolder: RecyclerView.ViewHolder,
+                ) = if (isProfileEditable((viewHolder).itemId)) {
+                    super.getSwipeDirs(recyclerView, viewHolder)
+                } else 0
+
+                override fun getDragDirs(
+                    recyclerView: RecyclerView,
+                    viewHolder: RecyclerView.ViewHolder,
+                ) = if (isEnabled) super.getDragDirs(recyclerView, viewHolder) else 0
+
+                override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+                    val index = viewHolder.adapterPosition
+                    adapter.remove(index)
+                    undoManager.remove(index to (viewHolder as ConfigurationHolder).item)
+                }
+
+                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(configurationList)
+        }
+
+
+        inner class ConfigurationAdapter : RecyclerView.Adapter<ConfigurationHolder>(),
+            ProfileManager.Listener {
+
+            var configurationList: MutableList<ProxyEntity> = mutableListOf()
+
+            init {
+                reloadProfiles(proxyGroup.id)
+                ProfileManager.addListener(this)
+            }
+
+            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 configurationList[position].id
+            }
+
+            override fun onBindViewHolder(holder: ConfigurationHolder, position: Int) {
+                holder.bind(configurationList[position])
+            }
+
+            override fun getItemCount(): Int {
+                return configurationList.size
+            }
+
+            private val updated = HashSet<ProxyEntity>()
+
+            fun move(from: Int, to: Int) {
+                val first = configurationList[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 = configurationList[i + step]
+                    val order = next.userOrder
+                    next.userOrder = previousOrder
+                    previousOrder = order
+                    configurationList[i] = next
+                    updated.add(next)
+                }
+                first.userOrder = previousOrder
+                configurationList[to] = first
+                updated.add(first)
+                notifyItemMoved(from, to)
+            }
+
+            fun commitMove() {
+                updated.forEach { SagerDatabase.proxyDao.updateProxy(it) }
+                updated.clear()
+            }
+
+            fun remove(pos: Int) {
+                configurationList.removeAt(pos)
+                notifyItemRemoved(pos)
+            }
+
+            fun undo(actions: List<Pair<Int, ProxyEntity>>) {
+                for ((index, item) in actions) {
+                    configurationList.add(index, item)
+                    notifyItemInserted(index)
+                }
+            }
+
+            fun commit(actions: List<Pair<Int, ProxyEntity>>) {
+                for ((_, item) in actions) {
+                    ProfileManager.deleteProfile(item.groupId, item.id)
+                }
+            }
+
+            override fun onAdd(profile: ProxyEntity) {
+                if (profile.groupId != proxyGroup.id) return
+                undoManager.flush()
+                val pos = itemCount
+                configurationList.add(profile)
+                notifyItemInserted(pos)
+            }
+
+            override fun onUpdated(profile: ProxyEntity) {
+                if (profile.groupId != proxyGroup.id) return
+                undoManager.flush()
+                runOnDefaultDispatcher {
+                    val index = configurationList.indexOfFirst { it.id == profile.id }
+                    if (index < 0) return@runOnDefaultDispatcher
+                    configurationList[index] = ProfileManager.getProfile(profile.id)!!
+                    notifyItemChanged(index)
+                }
+            }
+
+            override fun onRemoved(groupId: Long, profileId: Long) {
+                if (groupId != proxyGroup.id) return
+                runOnDefaultDispatcher {
+                    val index = configurationList.indexOfFirst { it.id == profileId }
+                    if (index < 0) return@runOnDefaultDispatcher
+                    configurationList.removeAt(index)
+                    if (profileId == DataStore.selectedProxy) {
+                        if (configurationList.isNotEmpty()) {
+                            DataStore.selectedProxy = configurationList[0].id
+                        } else {
+                            DataStore.selectedProxy = 0
+                        }
+                    }
+                    onMainDispatcher {
+                        notifyItemRemoved(index)
+                    }
+                }
+
+            }
+
+            override fun onCleared(groupId: Long) {
+                if (groupId != proxyGroup.id) return
+                configurationList.clear()
+                notifyDataSetChanged()
+            }
+
+            override fun reloadProfiles(groupId: Long) {
+                if (groupId != proxyGroup.id) return
+                runOnDefaultDispatcher {
+                    configurationList.clear()
+                    configurationList.addAll(SagerDatabase.proxyDao.getByGroup(proxyGroup.id))
+                    onMainDispatcher {
+                        notifyDataSetChanged()
+                    }
+                }
+            }
+
+            fun refreshId(profileId: Long) {
+                runOnDefaultDispatcher {
+                    val index = configurationList.indexOfFirst { it.id == profileId }
+                    if (index < 0) return@runOnDefaultDispatcher
+                    onMainDispatcher {
+                        notifyItemChanged(index)
+                    }
+                }
+            }
+
+        }
+
+        override fun onDestroyView() {
+            super.onDestroyView()
+
+            undoManager.flush()
+        }
+
+        inner class ConfigurationHolder(val view: View) : RecyclerView.ViewHolder(view) {
+
+            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 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)
+            lateinit var item: ProxyEntity
+
+            fun bind(proxyEntity: ProxyEntity) {
+                item = proxyEntity
+                view.setOnClickListener {
+                    runOnDefaultDispatcher {
+                        if (DataStore.selectedProxy != proxyEntity.id) {
+                            val lastSelected = DataStore.selectedProxy
+                            DataStore.selectedProxy = proxyEntity.id
+                            onMainDispatcher {
+                                adapter.refreshId(lastSelected)
+                                selectedView.visibility = View.VISIBLE
+                            }
+                        }
+                    }
+                }
+
+                profileName.text = proxyEntity.displayName()
+                profileType.text = proxyEntity.displayType()
+                val showTraffic = proxyEntity.rx + proxyEntity.tx != 0L
+                trafficText.isGone = !showTraffic
+                if (showTraffic) {
+                    trafficText.text = view.context.getString(R.string.traffic,
+                        Formatter.formatFileSize(view.context, proxyEntity.rx),
+                        Formatter.formatFileSize(view.context, proxyEntity.tx))
+                }
+
+                if (proxyEntity.requireBean().name.isNullOrBlank()) {
+                    profileAddress.isGone = true
+                } else {
+                    profileAddress.isGone = false
+                    val bean = proxyEntity.requireBean()
+                    @SuppressLint("SetTextI18n")
+                    profileAddress.text = "${bean.serverAddress}:${bean.serverPort}"
+                }
+
+                editButton.setOnClickListener {
+                    it.context.startActivity(proxyEntity.settingIntent(it.context))
+                }
+
+                runOnDefaultDispatcher {
+                    val selected = DataStore.selectedProxy == proxyEntity.id
+                    onMainDispatcher {
+                        selectedView.visibility = if (selected) View.VISIBLE else View.INVISIBLE
+                    }
+
+                }
+
+            }
+
+        }
+
     }
 
+
 }

+ 2 - 2
app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt

@@ -20,7 +20,7 @@ import com.google.android.material.navigation.NavigationView
 import com.google.android.material.snackbar.Snackbar
 import io.nekohasekai.sagernet.Key
 import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.SagerNet
 import io.nekohasekai.sagernet.bg.BaseService
 import io.nekohasekai.sagernet.bg.SagerConnection
 import io.nekohasekai.sagernet.database.DataStore
@@ -51,7 +51,7 @@ class MainActivity : AppCompatActivity(), SagerConnection.Callback,
         val navView: NavigationView = findViewById(R.id.nav_view)
         val navController = findNavController(R.id.nav_host_fragment)
 
-        fab.setOnClickListener { if (state.canStop) SagerApp.stopService() else connect.launch(null) }
+        fab.setOnClickListener { if (state.canStop) SagerNet.stopService() else connect.launch(null) }
         stats.setOnClickListener { if (state == BaseService.State.Connected) stats.testConnection() }
 
         ViewCompat.setOnApplyWindowInsetsListener(coordinator, ListHolderListener)

+ 3 - 3
app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt

@@ -14,7 +14,7 @@ import androidx.appcompat.app.AppCompatActivity
 import androidx.core.content.getSystemService
 import io.nekohasekai.sagernet.Key
 import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.SagerNet
 import io.nekohasekai.sagernet.database.DataStore
 import io.nekohasekai.sagernet.ktx.Logs
 import io.nekohasekai.sagernet.ktx.broadcastReceiver
@@ -51,7 +51,7 @@ class VpnRequestActivity : AppCompatActivity() {
                 cachedIntent = intent
                 return null
             }
-            SagerApp.startService()
+            SagerNet.startService()
             return SynchronousResult(false)
         }
 
@@ -60,7 +60,7 @@ class VpnRequestActivity : AppCompatActivity() {
 
         override fun parseResult(resultCode: Int, intent: Intent?) =
             if (resultCode == Activity.RESULT_OK) {
-                SagerApp.startService()
+                SagerNet.startService()
                 false
             } else {
                 Logs.e("Failed to start VpnService: $intent")

+ 0 - 70
app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationAdapter.kt

@@ -1,70 +0,0 @@
-package io.nekohasekai.sagernet.ui.configuration
-
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.database.DataStore
-import io.nekohasekai.sagernet.database.ProxyEntity
-import io.nekohasekai.sagernet.database.SagerDatabase
-import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
-import io.nekohasekai.sagernet.ktx.*
-
-class ConfigurationAdapter(private val groupIdToQuery: Long) :
-    RecyclerView.Adapter<ConfigurationHolder>(), EventListener {
-
-    init {
-        registerListener(EVENT_UPDATE_GROUP, this)
-    }
-
-    override fun onEvent(eventId: Int, vararg args: Any) {
-        if (args[0] != groupIdToQuery) return
-        reloadList()
-    }
-
-    var configurationList: List<ProxyEntity> = listOf()
-
-    fun reloadList() {
-        runOnDefaultDispatcher {
-            configurationList = SagerDatabase.proxyDao.getByGroup(groupIdToQuery)
-            if (configurationList.isEmpty() &&
-                (SagerDatabase.groupDao.getById(groupIdToQuery)
-                    ?: return@runOnDefaultDispatcher).isDefault
-            ) {
-                SagerDatabase.proxyDao.addProxy(ProxyEntity(
-                    groupId = groupIdToQuery,
-                    type = "socks",
-                    socksBean = SOCKSBean().apply {
-                        serverAddress = "127.0.0.1"
-                        serverPort = 1080
-                        name = "Hello W0rld!"
-                    }
-                ))
-                configurationList = SagerDatabase.proxyDao.getByGroup(groupIdToQuery)
-                DataStore.selectedProxy = configurationList[0].id
-            }
-            onMainDispatcher {
-                notifyDataSetChanged()
-            }
-        }
-    }
-
-    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 configurationList[position].id
-    }
-
-    override fun onBindViewHolder(holder: ConfigurationHolder, position: Int) {
-        holder.bind(configurationList[position])
-    }
-
-    override fun getItemCount(): Int {
-        return configurationList.size
-    }
-
-}

+ 0 - 99
app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationHolder.kt

@@ -1,99 +0,0 @@
-package io.nekohasekai.sagernet.ui.configuration
-
-import android.content.Intent
-import android.text.format.Formatter
-import android.view.View
-import android.widget.ImageView
-import android.widget.LinearLayout
-import android.widget.TextView
-import androidx.core.view.isGone
-import androidx.recyclerview.widget.RecyclerView
-import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.database.DataStore
-import io.nekohasekai.sagernet.database.ProxyEntity
-import io.nekohasekai.sagernet.database.SagerDatabase
-import io.nekohasekai.sagernet.ktx.*
-import io.nekohasekai.sagernet.ui.settings.ProfileSettingsActivity
-import io.nekohasekai.sagernet.ui.settings.SocksSettingsActivity
-
-class ConfigurationHolder(val view: View) : RecyclerView.ViewHolder(view), EventListener {
-
-    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 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)
-
-    var profileId = 0L
-
-    init {
-        registerListener(EVENT_UPDATE_PROFILE, this)
-    }
-
-    override fun onEvent(eventId: Int, vararg args: Any) {
-        if (args[0] != profileId) return
-        runOnDefaultDispatcher {
-            val profile = SagerDatabase.proxyDao.getById(profileId) ?: return@runOnDefaultDispatcher
-            onMainDispatcher {
-                bind(profile)
-            }
-        }
-    }
-
-    fun bind(proxyEntity: ProxyEntity) {
-        profileId = proxyEntity.id
-
-        view.setOnClickListener {
-            runOnDefaultDispatcher {
-                if (DataStore.selectedProxy != proxyEntity.id) {
-                    DataStore.selectedProxy = proxyEntity.id
-                    onMainDispatcher {
-                        bind(proxyEntity)
-                    }
-                }
-            }
-        }
-
-        profileName.text = proxyEntity.displayName()
-        profileType.text = proxyEntity.displayType()
-        val showTraffic = proxyEntity.rx + proxyEntity.tx != 0L
-        trafficText.isGone = !showTraffic
-        if (showTraffic) {
-            trafficText.text = view.context.getString(R.string.traffic,
-                Formatter.formatFileSize(view.context, proxyEntity.rx),
-                Formatter.formatFileSize(view.context, proxyEntity.tx))
-        }
-
-        if (proxyEntity.requireBean().name.isNullOrBlank()) {
-            profileAddress.isGone = true
-        } else {
-            profileAddress.isGone = false
-            val bean = proxyEntity.requireBean()
-            profileAddress.text = "${bean.serverAddress}:${bean.serverPort}"
-        }
-
-        editButton.setOnClickListener {
-            it.context.startActivity(Intent(it.context, when (proxyEntity.type) {
-                "socks" -> SocksSettingsActivity::class.java
-                else -> throw IllegalArgumentException()
-            }).apply {
-                putExtra(ProfileSettingsActivity.EXTRA_PROFILE_ID, proxyEntity.id)
-            })
-        }
-
-        runOnDefaultDispatcher {
-            if (DataStore.selectedProxy == proxyEntity.id) {
-                onMainDispatcher {
-                    selectedView.visibility = View.VISIBLE
-                }
-            } else {
-                onMainDispatcher {
-                    selectedView.visibility = View.INVISIBLE
-                }
-            }
-        }
-
-    }
-
-}

+ 0 - 39
app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupFragment.kt

@@ -1,39 +0,0 @@
-package io.nekohasekai.sagernet.ui.configuration
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.fragment.app.Fragment
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import io.nekohasekai.sagernet.R
-import io.nekohasekai.sagernet.database.ProxyGroup
-
-class GroupFragment @JvmOverloads constructor(private val proxyGroup: ProxyGroup? = null) :
-    Fragment() {
-
-    override fun onCreateView(
-        inflater: LayoutInflater,
-        container: ViewGroup?,
-        savedInstanceState: Bundle?,
-    ): View? {
-        return inflater.inflate(R.layout.configurtion_list_main, container, false)
-    }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        val configurationList = view.findViewById<RecyclerView>(R.id.configuration_list)
-        if (proxyGroup == null) return
-
-        val adapter = ConfigurationAdapter(proxyGroup.id)
-
-        configurationList.layoutManager = when (proxyGroup.layout) {
-            else -> LinearLayoutManager(view.context)
-        }
-        configurationList.adapter = adapter
-
-        adapter.reloadList()
-
-    }
-
-}

+ 0 - 43
app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupPagerAdapter.kt

@@ -1,43 +0,0 @@
-package io.nekohasekai.sagernet.ui.configuration
-
-import androidx.fragment.app.Fragment
-import androidx.viewpager2.adapter.FragmentStateAdapter
-import io.nekohasekai.sagernet.database.ProxyGroup
-import io.nekohasekai.sagernet.database.SagerDatabase
-import io.nekohasekai.sagernet.ktx.onMainDispatcher
-
-class GroupPagerAdapter(
-    activity: Fragment,
-) : FragmentStateAdapter(activity) {
-
-    var groupList: List<ProxyGroup> = listOf()
-
-    suspend fun reloadList(hideTab: (hideTab: Boolean) -> Unit) {
-        groupList = SagerDatabase.groupDao.allGroups()
-        if (groupList.isEmpty()) {
-            SagerDatabase.groupDao.createGroup(ProxyGroup(isDefault = true))
-            groupList = SagerDatabase.groupDao.allGroups()
-        }
-        onMainDispatcher {
-            notifyDataSetChanged()
-            hideTab(groupList.size == 1 && groupList[0].isDefault)
-        }
-    }
-
-    override fun getItemCount(): Int {
-        return groupList.size
-    }
-
-    override fun createFragment(position: Int): Fragment {
-        return GroupFragment(groupList[position])
-    }
-
-    override fun getItemId(position: Int): Long {
-        return groupList[position].id
-    }
-
-    override fun containsItem(itemId: Long): Boolean {
-        return groupList.any { it.id == itemId }
-    }
-
-}

+ 11 - 16
app/src/main/java/io/nekohasekai/sagernet/ui/settings/ProfileSettingsActivity.kt

@@ -16,14 +16,11 @@ import androidx.preference.PreferenceFragmentCompat
 import io.nekohasekai.sagernet.Key
 import io.nekohasekai.sagernet.R
 import io.nekohasekai.sagernet.database.DataStore
-import io.nekohasekai.sagernet.database.ProxyEntity
+import io.nekohasekai.sagernet.database.ProfileManager
 import io.nekohasekai.sagernet.database.SagerDatabase
 import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener
 import io.nekohasekai.sagernet.fmt.AbstractBean
-import io.nekohasekai.sagernet.ktx.EVENT_UPDATE_GROUP
-import io.nekohasekai.sagernet.ktx.EVENT_UPDATE_PROFILE
 import io.nekohasekai.sagernet.ktx.Empty
-import io.nekohasekai.sagernet.ktx.postNotification
 import io.nekohasekai.sagernet.utils.AlertDialogFragment
 import io.nekohasekai.sagernet.widget.ListListener
 import kotlinx.parcelize.Parcelize
@@ -35,8 +32,12 @@ abstract class ProfileSettingsActivity<T : AbstractBean> : AppCompatActivity(),
     class UnsavedChangesDialogFragment : AlertDialogFragment<Empty, Empty>() {
         override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
             setTitle(R.string.unsaved_changes_prompt)
-            setPositiveButton(R.string.yes, listener)
-            setNegativeButton(R.string.no, listener)
+            setPositiveButton(R.string.yes) { _, _ ->
+                (requireActivity() as ProfileSettingsActivity<*>).saveAndExit()
+            }
+            setNegativeButton(R.string.no) { _, _ ->
+                requireActivity().finish()
+            }
             setNeutralButton(android.R.string.cancel, null)
         }
     }
@@ -47,8 +48,7 @@ abstract class ProfileSettingsActivity<T : AbstractBean> : AppCompatActivity(),
         override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
             setTitle(R.string.delete_confirm_prompt)
             setPositiveButton(R.string.yes) { _, _ ->
-                SagerDatabase.proxyDao.deleteById(arg.profileId)
-                postNotification(EVENT_UPDATE_GROUP, arg.groupId)
+                ProfileManager.deleteProfile(arg.groupId, arg.profileId)
                 requireActivity().finish()
             }
             setNegativeButton(R.string.no, null)
@@ -76,7 +76,6 @@ abstract class ProfileSettingsActivity<T : AbstractBean> : AppCompatActivity(),
             setDisplayHomeAsUpEnabled(true)
             setHomeAsUpIndicator(R.drawable.ic_navigation_close)
         }
-        DataStore.profileCacheStore.registerChangeListener(this)
 
         if (savedInstanceState == null) {
             val editingId = intent.getLongExtra(EXTRA_PROFILE_ID, 0L)
@@ -100,6 +99,7 @@ abstract class ProfileSettingsActivity<T : AbstractBean> : AppCompatActivity(),
                     MyPreferenceFragmentCompat().apply { activity = this@ProfileSettingsActivity })
                 .commit()
         }
+        DataStore.profileCacheStore.registerChangeListener(this)
 
     }
 
@@ -108,19 +108,14 @@ abstract class ProfileSettingsActivity<T : AbstractBean> : AppCompatActivity(),
         val editingId = DataStore.editingId
         if (editingId == 0L) {
             val editingGroup = DataStore.editingGroup
-            SagerDatabase.proxyDao.addProxy(ProxyEntity(
-                groupId = editingGroup,
-                type = type
-            ).apply { putBean(createEntity().apply { serialize() }) })
-            postNotification(EVENT_UPDATE_GROUP, editingGroup)
+            ProfileManager.createProfile(editingGroup, createEntity().apply { serialize() })
         } else {
             val entity = SagerDatabase.proxyDao.getById(DataStore.editingId)
             if (entity == null) {
                 finish()
                 return
             }
-            SagerDatabase.proxyDao.updateProxy(entity.apply { (requireBean() as T).serialize() })
-            postNotification(EVENT_UPDATE_PROFILE, editingId)
+            ProfileManager.updateProfile(entity.apply { (requireBean() as T).serialize() })
         }
         finish()
 

+ 0 - 9
app/src/main/java/io/nekohasekai/sagernet/ui/settings/SocksSettingsActivity.kt

@@ -1,23 +1,14 @@
 package io.nekohasekai.sagernet.ui.settings
 
 import android.os.Bundle
-import android.view.MenuItem
-import android.view.View
-import androidx.core.view.ViewCompat
 import androidx.preference.EditTextPreference
 import androidx.preference.PreferenceFragmentCompat
 import cn.hutool.core.util.NumberUtil
 import io.nekohasekai.sagernet.Key
 import io.nekohasekai.sagernet.R
 import io.nekohasekai.sagernet.database.DataStore
-import io.nekohasekai.sagernet.database.ProxyEntity
-import io.nekohasekai.sagernet.database.SagerDatabase
 import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers
 import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
-import io.nekohasekai.sagernet.ktx.EVENT_UPDATE_GROUP
-import io.nekohasekai.sagernet.ktx.EVENT_UPDATE_PROFILE
-import io.nekohasekai.sagernet.ktx.postNotification
-import io.nekohasekai.sagernet.widget.ListListener
 
 class SocksSettingsActivity : ProfileSettingsActivity<SOCKSBean>() {
 

+ 46 - 0
app/src/main/java/io/nekohasekai/sagernet/widget/UndoSnackbarManager.kt

@@ -0,0 +1,46 @@
+package io.nekohasekai.sagernet.widget
+
+import com.google.android.material.snackbar.Snackbar
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.ui.MainActivity
+
+/**
+ * @param activity MainActivity.
+ * //@param view The view to find a parent from.
+ * @param undo Callback for undoing removals.
+ * @param commit Callback for committing removals.
+ * @tparam T Item type.
+ */
+class UndoSnackbarManager<in T>(private val activity: MainActivity, private val undo: (List<Pair<Int, T>>) -> Unit,
+                                commit: ((List<Pair<Int, T>>) -> Unit)? = null) {
+    private val recycleBin = ArrayList<Pair<Int, T>>()
+    private val removedCallback = object : Snackbar.Callback() {
+        override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
+            if (last === transientBottomBar && event != DISMISS_EVENT_ACTION) {
+                commit?.invoke(recycleBin)
+                recycleBin.clear()
+                last = null
+            }
+        }
+    }
+
+    private var last: Snackbar? = null
+
+    fun remove(items: Collection<Pair<Int, T>>) {
+        recycleBin.addAll(items)
+        val count = recycleBin.size
+        activity.snackbar(activity.resources.getQuantityString(R.plurals.removed, count, count)).apply {
+            addCallback(removedCallback)
+            setAction(R.string.undo) {
+                undo(recycleBin.reversed())
+                recycleBin.clear()
+            }
+            last = this
+            show()
+        }
+    }
+
+    fun remove(vararg items: Pair<Int, T>) = remove(items.toList())
+
+    fun flush() = last?.dismiss()
+}

+ 14 - 8
app/src/main/res/layout/app_bar_main.xml

@@ -1,12 +1,18 @@
 <?xml version="1.0" encoding="utf-8"?>
-<com.google.android.material.appbar.MaterialToolbar xmlns:android="http://schemas.android.com/apk/res/android"
+<com.google.android.material.appbar.AppBarLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:id="@+id/toolbar"
+    android:id="@+id/appbar"
     android:layout_width="match_parent"
     android:layout_height="?attr/actionBarSize"
-    android:background="?attr/colorPrimary"
-    android:elevation="4dp"
-    android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
-    android:touchscreenBlocksFocus="false"
-    app:popupTheme="@style/ThemeOverlay.AppCompat.DayNight"
-    app:title="@string/app_name" />
+    android:elevation="4dp">
+
+    <com.google.android.material.appbar.MaterialToolbar
+        android:id="@+id/toolbar"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:background="?attr/colorPrimary"
+        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
+        android:touchscreenBlocksFocus="false"
+        app:popupTheme="@style/ThemeOverlay.AppCompat.DayNight"
+        app:title="@string/app_name" />
+</com.google.android.material.appbar.AppBarLayout>

+ 1 - 0
app/src/main/res/layout/configurtion_list_main.xml

@@ -4,4 +4,5 @@
     android:id="@+id/configuration_list"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
+    android:padding="4dp"
     tools:listitem="@layout/layout_profile" />

+ 1 - 1
app/src/main/res/layout/layout_profile.xml

@@ -5,7 +5,7 @@
     android:id="@+id/content"
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
-    android:layout_margin="8dp"
+    android:layout_margin="4dp"
     app:cardElevation="4dp">
 
     <LinearLayout

+ 10 - 9
app/src/main/res/menu/profile_manager_menu.xml

@@ -10,17 +10,18 @@
             <item
                 android:id="@+id/action_scan_qr_code"
                 android:title="@string/add_profile_methods_scan_qr_code" />
-            <item android:title="@string/action_profile">
+            <item
+                android:id="@+id/action_import_clipboard"
+                android:alphabeticShortcut="v"
+                android:title="@string/action_import" />
+            <item
+                android:id="@+id/action_manual_settings"
+                android:alphabeticShortcut="n"
+                android:title="@string/add_profile_methods_manual_settings">
                 <menu>
                     <item
-                        android:id="@+id/action_import_clipboard"
-                        android:alphabeticShortcut="v"
-                        android:title="@string/action_import" />
-                    <item
-                        android:id="@+id/action_manual_settings"
-                        android:alphabeticShortcut="n"
-                        android:title="@string/add_profile_methods_manual_settings"
-                        app:alphabeticModifiers="CTRL|SHIFT" />
+                        android:id="@+id/action_new_socks"
+                        android:title="@string/action_socks" />
                 </menu>
             </item>
             <item android:title="@string/action_group">

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -106,6 +106,7 @@
     <string name="share">Share</string>
     <string name="add_profile">Add Profile</string>
     <string name="action_profile">Profile</string>
+    <string name="action_socks">Socks</string>
     <string name="action_group">Group</string>
     <string name="action_from_link">From subscription</string>
     <string name="action_apply_all">Apply Settings to All Profiles</string>

+ 1 - 0
gradle.properties

@@ -17,5 +17,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
 android.useAndroidX=true
 # Automatically convert third-party libraries to use AndroidX
 android.enableJetifier=true
+android.enableR8.fullMode=true
 # Kotlin code style for this project: "official" or "obsolete":
 kotlin.code.style=official