Remove legacy View-based UI

Keep only Compose UI, removing old View-based Activities, Fragments,
and layouts. QRScanActivity and PerAppProxyActivity are retained as
they are still used by Compose UI.
This commit is contained in:
世界
2025-12-30 18:01:54 +08:00
parent c3478efc3c
commit 71b936ba3a
52 changed files with 12 additions and 5779 deletions

View File

@@ -4,25 +4,13 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import io.nekohasekai.sfa.compose.ComposeActivity
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ui.MainActivity
import kotlinx.coroutines.runBlocking
class LauncherActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val targetActivity =
if (BuildConfig.DEBUG) {
val useComposeUI = runBlocking { Settings.useComposeUI }
if (useComposeUI) ComposeActivity::class.java else MainActivity::class.java
} else {
ComposeActivity::class.java
}
val launchIntent =
Intent(this, targetActivity).apply {
// Transfer any intent data from launcher
Intent(this, ComposeActivity::class.java).apply {
intent?.let {
action = it.action
data = it.data

View File

@@ -34,7 +34,7 @@ import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.hasPermission
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.compose.ComposeActivity
import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -397,7 +397,7 @@ class BoxService(
0,
Intent(
service,
MainActivity::class.java,
ComposeActivity::class.java,
).apply {
setAction(Action.OPEN_URL).setData(Uri.parse(notification.openURL))
setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)

View File

@@ -319,64 +319,6 @@ fun SettingsScreen(navController: NavController) {
}
}
if (BuildConfig.DEBUG) {
// Debug
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.title_debug),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.switch_to_legacy_ui),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.SwapHoriz,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
Modifier
.clip(RoundedCornerShape(12.dp))
.clickable {
scope.launch(Dispatchers.IO) {
Settings.useComposeUI = false
val intent =
android.content.Intent(
context,
Class.forName("io.nekohasekai.sfa.ui.MainActivity"),
)
intent.flags =
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
}
},
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}

View File

@@ -11,7 +11,6 @@ object SettingsKey {
const val AUTO_UPDATE_ENABLED = "auto_update_enabled"
const val DISABLE_MEMORY_LIMIT = "disable_memory_limit"
const val DYNAMIC_NOTIFICATION = "dynamic_notification"
const val USE_COMPOSE_UI = "use_compose_ui"
const val DISABLE_DEPRECATED_WARNINGS = "disable_deprecated_warnings"
const val AUTO_REDIRECT = "auto_redirect"

View File

@@ -65,7 +65,6 @@ object Settings {
var autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false }
var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT)
var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true }
var useComposeUI by dataStore.boolean(SettingsKey.USE_COMPOSE_UI) { true }
var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false }
const val PER_APP_PROXY_DISABLED = 0

View File

@@ -2,18 +2,11 @@ package io.nekohasekai.sfa.ktx
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import androidx.core.content.FileProvider
import androidx.fragment.app.FragmentActivity
import androidx.appcompat.R as AppCompatR
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.ProfileContent
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.ui.shared.QRCodeDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
@@ -51,27 +44,3 @@ suspend fun Context.shareProfile(profile: Profile) {
)
}
}
fun FragmentActivity.shareProfileURL(profile: Profile) {
val link =
Libbox.generateRemoteProfileImportLink(
profile.name,
profile.typed.remoteURL,
)
val imageSize = dp2px(256)
val color = getAttrColor(androidx.appcompat.R.attr.colorPrimary)
val image = QRCodeWriter().encode(link, BarcodeFormat.QR_CODE, imageSize, imageSize, null)
val imageWidth = image.width
val imageHeight = image.height
val imageArray = IntArray(imageWidth * imageHeight)
for (y in 0 until imageHeight) {
val offset = y * imageWidth
for (x in 0 until imageWidth) {
imageArray[offset + x] = if (image.get(x, y)) color else Color.TRANSPARENT
}
}
val bitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888)
bitmap.setPixels(imageArray, 0, imageSize, 0, 0, imageWidth, imageHeight)
val dialog = QRCodeDialog(bitmap)
dialog.show(supportFragmentManager, "share-profile-url")
}

View File

@@ -1,445 +0,0 @@
package io.nekohasekai.sfa.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.text.Html
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.ProfileContent
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.bg.ServiceNotification
import io.nekohasekai.sfa.constant.Action
import io.nekohasekai.sfa.constant.Alert
import io.nekohasekai.sfa.constant.ServiceMode
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.databinding.ActivityMainBinding
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.hasPermission
import io.nekohasekai.sfa.ktx.launchCustomTab
import io.nekohasekai.sfa.ui.profile.NewProfileActivity
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import io.nekohasekai.sfa.utils.MIUIUtils
import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Date
class MainActivity :
AbstractActivity<ActivityMainBinding>(),
ServiceConnection.Callback {
companion object {
private const val TAG = "MainActivity"
}
private lateinit var navHostFragment: NavHostFragment
private lateinit var navController: NavController
private lateinit var appBarConfiguration: AppBarConfiguration
private val connection = ServiceConnection(this, this)
val serviceStatus = MutableLiveData(Status.Stopped)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_my) as NavHostFragment
navController = navHostFragment.navController
navController.setGraph(R.navigation.mobile_navigation)
navController.addOnDestinationChangedListener(::onDestinationChanged)
appBarConfiguration =
AppBarConfiguration(
setOf(
R.id.navigation_dashboard,
R.id.navigation_log,
R.id.navigation_configuration,
R.id.navigation_settings,
),
)
setupActionBarWithNavController(navController, appBarConfiguration)
binding.navView.setupWithNavController(navController)
reconnect()
startIntegration()
onNewIntent(intent)
}
override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp(appBarConfiguration)
}
@Suppress("UNUSED_PARAMETER")
private fun onDestinationChanged(
navController: NavController,
navDestination: NavDestination,
bundle: Bundle?,
) {
val destinationId = navDestination.id
binding.dashboardTabContainer.isVisible = destinationId == R.id.navigation_dashboard
}
public override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val uri = intent.data ?: return
when (intent.action) {
Action.OPEN_URL -> {
launchCustomTab(uri.toString())
return
}
}
if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") {
val profile =
try {
Libbox.parseRemoteProfileImportLink(uri.toString())
} catch (e: Exception) {
errorDialogBuilder(e).show()
return
}
MaterialAlertDialogBuilder(this)
.setTitle(R.string.import_remote_profile)
.setMessage(
getString(
R.string.import_remote_profile_message,
profile.name,
profile.host,
),
)
.setPositiveButton(R.string.ok) { _, _ ->
startActivity(
Intent(this, NewProfileActivity::class.java).apply {
putExtra("importName", profile.name)
putExtra("importURL", profile.url)
},
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
} else if (intent.action == Intent.ACTION_VIEW) {
try {
val data = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return
val content = Libbox.decodeProfileContent(data)
MaterialAlertDialogBuilder(this)
.setTitle(R.string.import_profile)
.setMessage(
getString(
R.string.import_profile_message,
content.name,
),
)
.setPositiveButton(R.string.ok) { _, _ ->
lifecycleScope.launch {
withContext(Dispatchers.IO) {
runCatching {
importProfile(content)
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it).show()
}
}
}
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
} catch (e: Exception) {
errorDialogBuilder(e).show()
}
}
}
private suspend fun importProfile(content: ProfileContent) {
val typedProfile = TypedProfile()
val profile = Profile(name = content.name, typed = typedProfile)
profile.userOrder = ProfileManager.nextOrder()
when (content.type) {
Libbox.ProfileTypeLocal -> {
typedProfile.type = TypedProfile.Type.Local
}
Libbox.ProfileTypeiCloud -> {
errorDialogBuilder(R.string.icloud_profile_unsupported).show()
return
}
Libbox.ProfileTypeRemote -> {
typedProfile.type = TypedProfile.Type.Remote
typedProfile.remoteURL = content.remotePath
typedProfile.autoUpdate = content.autoUpdate
typedProfile.autoUpdateInterval = content.autoUpdateInterval
typedProfile.lastUpdated = Date(content.lastUpdated)
}
}
val configDirectory = File(filesDir, "configs").also { it.mkdirs() }
val configFile = File(configDirectory, "${profile.userOrder}.json")
configFile.writeText(content.config)
typedProfile.path = configFile.path
ProfileManager.create(profile)
}
fun reconnect() {
connection.reconnect()
}
private fun startIntegration() {
lifecycleScope.launch(Dispatchers.IO) {
if (Settings.checkUpdateEnabled) {
Vendor.checkUpdate(this@MainActivity, false)
}
}
}
@SuppressLint("NewApi")
fun startService() {
if (!ServiceNotification.checkPermission()) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
startService0()
}
private fun startService0() {
lifecycleScope.launch(Dispatchers.IO) {
if (Settings.rebuildServiceMode()) {
reconnect()
}
if (Settings.serviceMode == ServiceMode.VPN) {
if (prepare()) {
return@launch
}
}
val intent = Intent(Application.application, Settings.serviceClass())
withContext(Dispatchers.Main) {
ContextCompat.startForegroundService(Application.application, intent)
}
}
}
private val notificationPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) {
if (Settings.dynamicNotification && !it) {
onServiceAlert(Alert.RequestNotificationPermission, null)
} else {
startService0()
}
}
private val locationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) {
if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
requestBackgroundLocationPermission()
} else {
startService()
}
}
}
private val backgroundLocationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) {
startService()
}
}
private val prepareLauncher =
registerForActivityResult(PrepareService()) {
if (it) {
startService()
} else {
onServiceAlert(Alert.RequestVPNPermission, null)
}
}
private class PrepareService : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(
context: Context,
input: Intent,
): Intent {
return input
}
override fun parseResult(
resultCode: Int,
intent: Intent?,
): Boolean {
return resultCode == RESULT_OK
}
}
private suspend fun prepare() =
withContext(Dispatchers.Main) {
try {
val intent = VpnService.prepare(this@MainActivity)
if (intent != null) {
prepareLauncher.launch(intent)
true
} else {
false
}
} catch (e: Exception) {
onServiceAlert(Alert.RequestVPNPermission, e.message)
false
}
}
override fun onServiceStatusChanged(status: Status) {
serviceStatus.postValue(status)
}
override fun onServiceAlert(
type: Alert,
message: String?,
) {
when (type) {
Alert.RequestLocationPermission -> {
return requestLocationPermission()
}
else -> {}
}
val builder = MaterialAlertDialogBuilder(this)
builder.setPositiveButton(R.string.ok, null)
when (type) {
Alert.RequestVPNPermission -> {
builder.setMessage(getString(R.string.service_error_missing_permission))
}
Alert.RequestNotificationPermission -> {
builder.setTitle(R.string.notification_permission_title)
builder.setMessage(R.string.notification_permission_required_description)
}
Alert.EmptyConfiguration -> {
builder.setMessage(getString(R.string.service_error_empty_configuration))
}
Alert.StartCommandServer -> {
builder.setTitle(getString(R.string.service_error_title_start_command_server))
builder.setMessage(message)
}
Alert.CreateService -> {
builder.setTitle(getString(R.string.service_error_title_create_service))
builder.setMessage(message)
}
Alert.StartService -> {
builder.setTitle(getString(R.string.service_error_title_start_service))
builder.setMessage(message)
}
else -> {}
}
builder.show()
}
private fun requestLocationPermission() {
if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
requestFineLocationPermission()
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
requestBackgroundLocationPermission()
}
}
private fun requestFineLocationPermission() {
val message =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Html.fromHtml(
getString(R.string.location_permission_description),
Html.FROM_HTML_MODE_LEGACY,
)
} else {
@Suppress("DEPRECATION")
Html.fromHtml(getString(R.string.location_permission_description))
}
MaterialAlertDialogBuilder(this)
.setTitle(R.string.location_permission_title)
.setMessage(message)
.setPositiveButton(R.string.ok) { _, _ ->
requestFineLocationPermission0()
}
.setNegativeButton(R.string.no_thanks, null)
.setCancelable(false)
.show()
}
private fun requestFineLocationPermission0() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
} else {
openPermissionSettings()
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun requestBackgroundLocationPermission() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.location_permission_title)
.setMessage(
Html.fromHtml(
getString(R.string.location_permission_background_description),
Html.FROM_HTML_MODE_LEGACY,
),
)
.setPositiveButton(R.string.ok) { _, _ ->
backgroundLocationPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
.setNegativeButton(R.string.no_thanks, null)
.setCancelable(false)
.show()
}
private fun openPermissionSettings() {
if (MIUIUtils.isMIUI) {
try {
MIUIUtils.openPermissionSettings(this)
return
} catch (ignored: Exception) {
}
}
try {
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:$packageName")
startActivity(intent)
} catch (e: Exception) {
errorDialogBuilder(e).show()
}
}
override fun onDestroy() {
connection.disconnect()
super.onDestroy()
}
}

View File

@@ -1,93 +0,0 @@
package io.nekohasekai.sfa.ui
import android.app.Activity
import android.app.KeyguardManager
import android.content.Intent
import android.content.pm.ShortcutManager
import android.os.Build
import android.os.Bundle
import androidx.core.content.getSystemService
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.constant.Status
class ShortcutActivity : Activity(), ServiceConnection.Callback {
private val connection = ServiceConnection(this, this, false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.action == Intent.ACTION_CREATE_SHORTCUT) {
setResult(
RESULT_OK,
ShortcutManagerCompat.createShortcutResultIntent(
this,
ShortcutInfoCompat.Builder(this, "toggle")
.setIntent(
Intent(
this,
ShortcutActivity::class.java,
).setAction(Intent.ACTION_MAIN),
)
.setIcon(
IconCompat.createWithResource(
this,
R.mipmap.ic_launcher,
),
)
.setShortLabel(getString(R.string.quick_toggle))
.build(),
),
)
finish()
} else {
val keyguardManager = getSystemService<KeyguardManager>()
if (keyguardManager?.isKeyguardLocked == true) {
if (Build.VERSION.SDK_INT >= 26) {
keyguardManager.requestDismissKeyguard(this, object : KeyguardManager.KeyguardDismissCallback() {
override fun onDismissSucceeded() {
super.onDismissSucceeded()
connectAndToggle()
}
override fun onDismissCancelled() {
super.onDismissCancelled()
finish()
}
override fun onDismissError() {
super.onDismissError()
finish()
}
})
} else {
finish()
}
} else {
connectAndToggle()
}
}
}
private fun connectAndToggle() {
connection.connect()
if (Build.VERSION.SDK_INT >= 25) {
getSystemService<ShortcutManager>()?.reportShortcutUsed("toggle")
}
}
override fun onServiceStatusChanged(status: Status) {
when (status) {
Status.Started -> BoxService.stop()
Status.Stopped -> BoxService.start()
else -> {}
}
finish()
}
override fun onDestroy() {
connection.disconnect()
super.onDestroy()
}
}

View File

@@ -1,343 +0,0 @@
package io.nekohasekai.sfa.ui.dashboard
import android.annotation.SuppressLint
import android.os.Bundle
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.textfield.MaterialAutoCompleteTextView
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.databinding.FragmentDashboardGroupsBinding
import io.nekohasekai.sfa.databinding.ViewDashboardGroupBinding
import io.nekohasekai.sfa.databinding.ViewDashboardGroupItemBinding
import io.nekohasekai.sfa.ktx.colorForURLTestDelay
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.text
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.utils.CommandClient
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class GroupsFragment : Fragment(), CommandClient.Handler {
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
private var binding: FragmentDashboardGroupsBinding? = null
private var adapter: Adapter? = null
private val commandClient =
CommandClient(lifecycleScope, CommandClient.ConnectionType.Groups, this)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentDashboardGroupsBinding.inflate(inflater, container, false)
this.binding = binding
onCreate()
return binding.root
}
private fun onCreate() {
val activity = activity ?: return
val binding = binding ?: return
adapter = Adapter()
binding.container.adapter = adapter
binding.container.layoutManager = LinearLayoutManager(requireContext())
activity.serviceStatus.observe(viewLifecycleOwner) {
if (it == Status.Started) {
commandClient.connect()
}
}
}
override fun onDestroyView() {
super.onDestroyView()
binding = null
commandClient.disconnect()
}
private var displayed = false
private fun updateDisplayed(newValue: Boolean) {
val binding = binding ?: return
if (displayed != newValue) {
displayed = newValue
binding.statusText.isVisible = !displayed
binding.container.isVisible = displayed
}
}
override fun onConnected() {
lifecycleScope.launch(Dispatchers.Main) {
updateDisplayed(true)
}
}
override fun onDisconnected() {
lifecycleScope.launch(Dispatchers.Main) {
updateDisplayed(false)
}
}
@SuppressLint("NotifyDataSetChanged")
override fun updateGroups(newGroups: MutableList<OutboundGroup>) {
val adapter = adapter ?: return
activity?.runOnUiThread {
updateDisplayed(newGroups.isNotEmpty())
adapter.setGroups(newGroups.map(::Group))
}
}
private class Adapter : RecyclerView.Adapter<GroupView>() {
private lateinit var groups: MutableList<Group>
@SuppressLint("NotifyDataSetChanged")
fun setGroups(newGroups: List<Group>) {
if (!::groups.isInitialized || groups.size != newGroups.size) {
groups = newGroups.toMutableList()
notifyDataSetChanged()
} else {
newGroups.forEachIndexed { index, group ->
if (this.groups[index] != group) {
this.groups[index] = group
notifyItemChanged(index)
}
}
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): GroupView {
return GroupView(
ViewDashboardGroupBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
),
)
}
override fun getItemCount(): Int {
if (!::groups.isInitialized) {
return 0
}
return groups.size
}
override fun onBindViewHolder(
holder: GroupView,
position: Int,
) {
holder.bind(groups[position])
}
}
private class GroupView(val binding: ViewDashboardGroupBinding) :
RecyclerView.ViewHolder(binding.root) {
private lateinit var group: Group
private lateinit var items: List<GroupItem>
private lateinit var adapter: ItemAdapter
private var textWatcher: TextWatcher? = null
@OptIn(DelicateCoroutinesApi::class)
@SuppressLint("NotifyDataSetChanged")
fun bind(group: Group) {
this.group = group
binding.groupName.text = group.tag
binding.groupType.text = Libbox.proxyDisplayType(group.type)
binding.urlTestButton.setOnClickListener {
GlobalScope.launch {
runCatching {
Libbox.newStandaloneCommandClient().urlTest(group.tag)
}.onFailure {
withContext(Dispatchers.Main) {
binding.root.context.errorDialogBuilder(it).show()
}
}
}
}
items = group.items
if (!::adapter.isInitialized) {
adapter = ItemAdapter(this, group, items.toMutableList())
binding.itemList.adapter = adapter
(binding.itemList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations =
false
binding.itemList.layoutManager = GridLayoutManager(binding.root.context, 2)
} else {
adapter.group = group
adapter.setItems(items)
}
updateExpand()
}
@OptIn(DelicateCoroutinesApi::class)
private fun updateExpand(isExpand: Boolean? = null) {
val newExpandStatus = isExpand ?: group.isExpand
if (isExpand != null) {
GlobalScope.launch {
runCatching {
Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, isExpand)
}.onFailure {
withContext(Dispatchers.Main) {
binding.root.context.errorDialogBuilder(it).show()
}
}
}
}
binding.itemList.isVisible = newExpandStatus
binding.groupSelected.isVisible = !newExpandStatus
val textView = (binding.groupSelected.editText as MaterialAutoCompleteTextView)
if (textWatcher != null) {
textView.removeTextChangedListener(textWatcher)
}
if (!newExpandStatus) {
binding.groupSelected.text = group.selected
binding.groupSelected.isEnabled = group.selectable
if (group.selectable) {
textView.setSimpleItems(group.items.toList().map { it.tag }.toTypedArray())
textWatcher =
textView.addTextChangedListener {
val selected = textView.text.toString()
if (selected != group.selected) {
updateSelected(group, selected)
}
GlobalScope.launch {
runCatching {
Libbox.newStandaloneCommandClient()
.selectOutbound(group.tag, selected)
}.onFailure {
withContext(Dispatchers.Main) {
binding.root.context.errorDialogBuilder(it).show()
}
}
}
}
}
}
if (newExpandStatus) {
binding.urlTestButton.isVisible = true
binding.expandButton.setImageResource(R.drawable.ic_expand_less_24)
} else {
binding.urlTestButton.isVisible = false
binding.expandButton.setImageResource(R.drawable.ic_expand_more_24)
}
binding.expandButton.setOnClickListener {
updateExpand(!binding.itemList.isVisible)
}
}
fun updateSelected(
group: Group,
itemTag: String,
) {
val oldSelected = items.indexOfFirst { it.tag == group.selected }
group.selected = itemTag
if (oldSelected != -1) {
adapter.notifyItemChanged(oldSelected)
}
}
}
private class ItemAdapter(
val groupView: GroupView,
var group: Group,
private var items: MutableList<GroupItem> = mutableListOf(),
) :
RecyclerView.Adapter<ItemGroupView>() {
@SuppressLint("NotifyDataSetChanged")
fun setItems(newItems: List<GroupItem>) {
if (items.size != newItems.size) {
items = newItems.toMutableList()
notifyDataSetChanged()
} else {
newItems.forEachIndexed { index, item ->
if (items[index] != item) {
items[index] = item
notifyItemChanged(index)
}
}
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): ItemGroupView {
return ItemGroupView(
ViewDashboardGroupItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
),
)
}
override fun getItemCount(): Int {
return items.size
}
override fun onBindViewHolder(
holder: ItemGroupView,
position: Int,
) {
holder.bind(groupView, group, items[position])
}
}
private class ItemGroupView(val binding: ViewDashboardGroupItemBinding) :
RecyclerView.ViewHolder(binding.root) {
@OptIn(DelicateCoroutinesApi::class)
fun bind(
groupView: GroupView,
group: Group,
item: GroupItem,
) {
if (group.selectable) {
binding.itemCard.setOnClickListener {
binding.selectedView.isVisible = true
groupView.updateSelected(group, item.tag)
GlobalScope.launch {
runCatching {
Libbox.newStandaloneCommandClient().selectOutbound(group.tag, item.tag)
}.onFailure {
withContext(Dispatchers.Main) {
binding.root.context.errorDialogBuilder("select outbound: ${it.localizedMessage}")
.show()
}
}
}
}
}
binding.selectedView.isInvisible = group.selected != item.tag
binding.itemName.text = item.tag
binding.itemType.text = Libbox.proxyDisplayType(item.type)
binding.itemStatus.isVisible = item.urlTestTime > 0
if (item.urlTestTime > 0) {
binding.itemStatus.text = "${item.urlTestDelay}ms"
binding.itemStatus.setTextColor(
colorForURLTestDelay(
binding.root.context,
item.urlTestDelay,
),
)
}
}
}
}

View File

@@ -1,389 +0,0 @@
package io.nekohasekai.sfa.ui.dashboard
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDividerItemDecoration
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.databinding.FragmentDashboardOverviewBinding
import io.nekohasekai.sfa.databinding.ViewClashModeButtonBinding
import io.nekohasekai.sfa.databinding.ViewProfileItemBinding
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.getAttrColor
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.utils.CommandClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class OverviewFragment : Fragment() {
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
private var binding: FragmentDashboardOverviewBinding? = null
private val statusClient =
CommandClient(lifecycleScope, CommandClient.ConnectionType.Status, StatusClient())
private val clashModeClient =
CommandClient(lifecycleScope, CommandClient.ConnectionType.ClashMode, ClashModeClient())
private var adapter: Adapter? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentDashboardOverviewBinding.inflate(inflater, container, false)
this.binding = binding
onCreate()
return binding.root
}
private fun onCreate() {
val activity = activity ?: return
val binding = binding ?: return
binding.profileList.adapter =
Adapter(lifecycleScope, binding).apply {
adapter = this
reload()
}
binding.profileList.layoutManager = LinearLayoutManager(requireContext())
val divider = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)
divider.isLastItemDecorated = false
binding.profileList.addItemDecoration(divider)
activity.serviceStatus.observe(viewLifecycleOwner) {
binding.statusContainer.isVisible = it == Status.Starting || it == Status.Started
when (it) {
Status.Stopped -> {
binding.clashModeCard.isVisible = false
binding.systemProxyCard.isVisible = false
}
Status.Started -> {
statusClient.connect()
clashModeClient.connect()
reloadSystemProxyStatus()
}
else -> {}
}
}
ProfileManager.registerCallback(this::updateProfiles)
}
override fun onDestroyView() {
super.onDestroyView()
adapter = null
binding = null
statusClient.disconnect()
clashModeClient.disconnect()
ProfileManager.unregisterCallback(this::updateProfiles)
}
private fun updateProfiles() {
adapter?.reload()
}
private fun reloadSystemProxyStatus() {
val binding = binding ?: return
lifecycleScope.launch(Dispatchers.IO) {
val status = Libbox.newStandaloneCommandClient().systemProxyStatus
withContext(Dispatchers.Main) {
binding.systemProxyCard.isVisible = status.available
binding.systemProxySwitch.setOnCheckedChangeListener(null)
binding.systemProxySwitch.isChecked = status.enabled
var reloading = false
binding.systemProxySwitch.setOnCheckedChangeListener { buttonView, isChecked ->
synchronized(this@OverviewFragment) {
if (reloading) return@setOnCheckedChangeListener
reloading = true
binding.systemProxySwitch.isEnabled = false
lifecycleScope.launch(Dispatchers.IO) {
Settings.systemProxyEnabled = isChecked
runCatching {
Libbox.newStandaloneCommandClient().setSystemProxyEnabled(isChecked)
}.onFailure {
withContext(Dispatchers.Main) {
buttonView.context.errorDialogBuilder(it).show()
}
}
withContext(Dispatchers.Main) {
delay(1000L)
binding.systemProxySwitch.isEnabled = true
}
}
}
}
}
}
}
inner class StatusClient : CommandClient.Handler {
override fun onConnected() {
val binding = binding ?: return
lifecycleScope.launch(Dispatchers.Main) {
binding.memoryText.text = getString(R.string.loading)
binding.goroutinesText.text = getString(R.string.loading)
}
}
override fun onDisconnected() {
val binding = binding ?: return
lifecycleScope.launch(Dispatchers.Main) {
binding.memoryText.text = getString(R.string.loading)
binding.goroutinesText.text = getString(R.string.loading)
}
}
override fun updateStatus(status: StatusMessage) {
val binding = binding ?: return
lifecycleScope.launch(Dispatchers.Main) {
binding.memoryText.text = Libbox.formatBytes(status.memory)
binding.goroutinesText.text = status.goroutines.toString()
val trafficAvailable = status.trafficAvailable
binding.trafficContainer.isVisible = trafficAvailable
if (trafficAvailable) {
binding.inboundConnectionsText.text = status.connectionsIn.toString()
binding.outboundConnectionsText.text = status.connectionsOut.toString()
binding.uplinkText.text = Libbox.formatBytes(status.uplink) + "/s"
binding.downlinkText.text = Libbox.formatBytes(status.downlink) + "/s"
binding.uplinkTotalText.text = Libbox.formatBytes(status.uplinkTotal)
binding.downlinkTotalText.text = Libbox.formatBytes(status.downlinkTotal)
}
}
}
}
inner class ClashModeClient : CommandClient.Handler {
override fun initializeClashMode(
modeList: List<String>,
currentMode: String,
) {
val binding = binding ?: return
if (modeList.size > 1) {
lifecycleScope.launch(Dispatchers.Main) {
binding.clashModeCard.isVisible = true
binding.clashModeList.adapter = ClashModeAdapter(modeList, currentMode)
binding.clashModeList.layoutManager =
GridLayoutManager(
requireContext(),
if (modeList.size < 3) modeList.size else 3,
)
}
} else {
lifecycleScope.launch(Dispatchers.Main) {
binding.clashModeCard.isVisible = false
}
}
}
@SuppressLint("NotifyDataSetChanged")
override fun updateClashMode(newMode: String) {
val binding = binding ?: return
val adapter = binding.clashModeList.adapter as? ClashModeAdapter ?: return
adapter.selected = newMode
lifecycleScope.launch(Dispatchers.Main) {
adapter.notifyDataSetChanged()
}
}
}
private inner class ClashModeAdapter(
val items: List<String>,
var selected: String,
) :
RecyclerView.Adapter<ClashModeItemView>() {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): ClashModeItemView {
val view =
ClashModeItemView(
ViewClashModeButtonBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
),
)
view.binding.clashModeButton.clipToOutline = true
return view
}
override fun getItemCount(): Int {
return items.size
}
override fun onBindViewHolder(
holder: ClashModeItemView,
position: Int,
) {
holder.bind(items[position], selected)
}
}
private inner class ClashModeItemView(val binding: ViewClashModeButtonBinding) :
RecyclerView.ViewHolder(binding.root) {
@OptIn(DelicateCoroutinesApi::class)
fun bind(
item: String,
selected: String,
) {
binding.clashModeButtonText.text = item
if (item != selected) {
binding.clashModeButtonText.setTextColor(
binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimaryContainer),
)
binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle)
binding.clashModeButton.setOnClickListener {
runCatching {
Libbox.newStandaloneCommandClient().setClashMode(item)
clashModeClient.connect()
}.onFailure {
GlobalScope.launch(Dispatchers.Main) {
binding.root.context.errorDialogBuilder(it).show()
}
}
}
} else {
binding.clashModeButtonText.setTextColor(
binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimary),
)
binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle_active)
binding.clashModeButton.isClickable = false
}
}
}
class Adapter(
internal val scope: CoroutineScope,
internal val parent: FragmentDashboardOverviewBinding,
) :
RecyclerView.Adapter<Holder>() {
internal var items: MutableList<Profile> = mutableListOf()
internal var selectedProfileID = -1L
internal var lastSelectedIndex: Int? = null
internal fun reload() {
scope.launch(Dispatchers.IO) {
items = ProfileManager.list().toMutableList()
if (items.isNotEmpty()) {
selectedProfileID = Settings.selectedProfile
for ((index, profile) in items.withIndex()) {
if (profile.id == selectedProfileID) {
lastSelectedIndex = index
break
}
}
if (lastSelectedIndex == null) {
lastSelectedIndex = 0
selectedProfileID = items[0].id
Settings.selectedProfile = selectedProfileID
}
}
withContext(Dispatchers.Main) {
parent.statusText.isVisible = items.isEmpty()
parent.container.isVisible = items.isNotEmpty()
notifyDataSetChanged()
}
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): Holder {
return Holder(
this,
ViewProfileItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
),
)
}
override fun onBindViewHolder(
holder: Holder,
position: Int,
) {
holder.bind(items[position])
}
override fun getItemCount(): Int {
return items.size
}
}
class Holder(
private val adapter: Adapter,
private val binding: ViewProfileItemBinding,
) :
RecyclerView.ViewHolder(binding.root) {
internal fun bind(profile: Profile) {
binding.profileName.text = profile.name
binding.profileSelected.setOnCheckedChangeListener(null)
binding.profileSelected.isChecked = profile.id == adapter.selectedProfileID
binding.profileSelected.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
adapter.parent.profileList.isClickable = false
adapter.selectedProfileID = profile.id
adapter.lastSelectedIndex?.let { index ->
adapter.notifyItemChanged(index)
}
adapter.lastSelectedIndex = adapterPosition
adapter.scope.launch(Dispatchers.IO) {
switchProfile(profile)
withContext(Dispatchers.Main) {
adapter.parent.profileList.isEnabled = true
}
}
}
}
binding.root.setOnClickListener {
binding.profileSelected.toggle()
}
}
private suspend fun switchProfile(profile: Profile) {
Settings.selectedProfile = profile.id
val mainActivity = (binding.root.context as? MainActivity) ?: return
val started = mainActivity.serviceStatus.value == Status.Started
if (!started) {
return
}
val restart = Settings.rebuildServiceMode()
if (restart) {
mainActivity.reconnect()
BoxService.stop()
delay(1000L)
mainActivity.startService()
return
}
runCatching {
Libbox.newStandaloneCommandClient().serviceReload()
}.onFailure {
withContext(Dispatchers.Main) {
mainActivity.errorDialogBuilder(it).show()
}
}
}
}
}

View File

@@ -1,18 +0,0 @@
package io.nekohasekai.sfa.ui.debug
import android.content.Intent
import android.os.Bundle
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.databinding.ActivityDebugBinding
import io.nekohasekai.sfa.ui.shared.AbstractActivity
class DebugActivity : AbstractActivity<ActivityDebugBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_debug)
binding.scanVPNButton.setOnClickListener {
startActivity(Intent(this, VPNScanActivity::class.java))
}
}
}

View File

@@ -1,274 +0,0 @@
package io.nekohasekai.sfa.ui.debug
import android.Manifest
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.databinding.ActivityVpnScanBinding
import io.nekohasekai.sfa.databinding.ViewVpnAppItemBinding
import io.nekohasekai.sfa.ktx.dp2px
import io.nekohasekai.sfa.ktx.toStringIterator
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import io.nekohasekai.sfa.vendor.PackageQueryManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.zip.ZipFile
import kotlin.math.roundToInt
class VPNScanActivity : AbstractActivity<ActivityVpnScanBinding>() {
private var adapter: Adapter? = null
private val appInfoList = mutableListOf<AppInfo>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_scan_vpn)
ViewCompat.setOnApplyWindowInsetsListener(binding.scanVPNResult) { view, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
view.updatePadding(bottom = insets.bottom + dp2px(16))
WindowInsetsCompat.CONSUMED
}
binding.scanVPNResult.adapter =
Adapter().also {
adapter = it
}
binding.scanVPNResult.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch(Dispatchers.IO) {
scanVPN()
}
}
class VPNType(
val appType: String?,
val coreType: VPNCoreType?,
)
class VPNCoreType(
val coreType: String,
val corePath: String,
val goVersion: String,
)
class AppInfo(
val packageInfo: PackageInfo,
val vpnType: VPNType,
)
inner class Adapter : RecyclerView.Adapter<Holder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): Holder {
return Holder(ViewVpnAppItemBinding.inflate(layoutInflater, parent, false))
}
override fun getItemCount(): Int {
return appInfoList.size
}
override fun onBindViewHolder(
holder: Holder,
position: Int,
) {
holder.bind(appInfoList[position])
}
}
class Holder(
private val binding: ViewVpnAppItemBinding,
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(element: AppInfo) {
binding.appIcon.setImageDrawable(element.packageInfo.applicationInfo!!.loadIcon(binding.root.context.packageManager))
binding.appName.text =
element.packageInfo.applicationInfo!!.loadLabel(binding.root.context.packageManager)
binding.packageName.text = element.packageInfo.packageName
val appType = element.vpnType.appType
if (appType != null) {
binding.appTypeText.text = element.vpnType.appType
} else {
binding.appTypeText.setText(R.string.vpn_app_type_other)
}
val coreType = element.vpnType.coreType?.coreType
if (coreType != null) {
binding.coreTypeText.text = element.vpnType.coreType.coreType
} else {
binding.coreTypeText.setText(R.string.vpn_core_type_unknown)
}
val corePath = element.vpnType.coreType?.corePath.takeIf { !it.isNullOrBlank() }
if (corePath != null) {
binding.corePathLayout.isVisible = true
binding.corePathText.text = corePath
} else {
binding.corePathLayout.isVisible = false
}
val goVersion = element.vpnType.coreType?.goVersion.takeIf { !it.isNullOrBlank() }
if (goVersion != null) {
binding.goVersionLayout.isVisible = true
binding.goVersionText.text = goVersion
} else {
binding.goVersionLayout.isVisible = false
}
}
}
private suspend fun scanVPN() {
val adapter = adapter ?: return
val flag =
PackageManager.GET_SERVICES or
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
PackageManager.MATCH_UNINSTALLED_PACKAGES
} else {
@Suppress("DEPRECATION")
PackageManager.GET_UNINSTALLED_PACKAGES
}
val installedPackages = PackageQueryManager.getInstalledPackages(flag)
val vpnAppList =
installedPackages.filter {
it.services?.any { it.permission == Manifest.permission.BIND_VPN_SERVICE && it.applicationInfo != null }
?: false
}
for ((index, packageInfo) in vpnAppList.withIndex()) {
val appType = runCatching { getVPNAppType(packageInfo) }.getOrNull()
val coreType = runCatching { getVPNCoreType(packageInfo) }.getOrNull()
appInfoList.add(AppInfo(packageInfo, VPNType(appType, coreType)))
withContext(Dispatchers.Main) {
adapter.notifyItemInserted(index)
binding.scanVPNResult.scrollToPosition(index)
binding.scanVPNProgress.setProgressCompat(
(((index + 1).toFloat() / vpnAppList.size.toFloat()) * 100).roundToInt(),
true,
)
}
System.gc()
}
withContext(Dispatchers.Main) {
binding.scanVPNProgress.isVisible = false
}
}
companion object {
private val v2rayNGClasses =
listOf(
"com.v2ray.ang",
".dto.V2rayConfig",
".service.V2RayVpnService",
)
private val clashForAndroidClasses =
listOf(
"com.github.kr328.clash",
".core.Clash",
".service.TunService",
)
private val sfaClasses =
listOf(
"io.nekohasekai.sfa",
)
private val legacySagerNetClasses =
listOf(
"io.nekohasekai.sagernet",
".fmt.ConfigBuilder",
)
private val shadowsocksAndroidClasses =
listOf(
"com.github.shadowsocks",
".bg.VpnService",
"GuardedProcessPool",
)
}
private fun getVPNAppType(packageInfo: PackageInfo): String? {
ZipFile(File(packageInfo.applicationInfo!!.publicSourceDir)).use { packageFile ->
for (packageEntry in packageFile.entries()) {
if (!(
packageEntry.name.startsWith("classes") &&
packageEntry.name.endsWith(
".dex",
)
)
) {
continue
}
if (packageEntry.size > 15000000) {
continue
}
val input = packageFile.getInputStream(packageEntry).buffered()
val dexFile =
try {
DexBackedDexFile.fromInputStream(null, input)
} catch (e: Exception) {
Log.e("VPNScanActivity", "Failed to read dex file", e)
continue
}
for (clazz in dexFile.classes) {
val clazzName =
clazz.type.substring(1, clazz.type.length - 1)
.replace("/", ".")
.replace("$", ".")
for (v2rayNGClass in v2rayNGClasses) {
if (clazzName.contains(v2rayNGClass)) {
return "V2RayNG"
}
}
for (clashForAndroidClass in clashForAndroidClasses) {
if (clazzName.contains(clashForAndroidClass)) {
return "ClashForAndroid"
}
}
for (sfaClass in sfaClasses) {
if (clazzName.contains(sfaClass)) {
return "sing-box"
}
}
for (legacySagerNetClass in legacySagerNetClasses) {
if (clazzName.contains(legacySagerNetClass)) {
return "LegacySagerNet"
}
}
for (shadowsocksAndroidClass in shadowsocksAndroidClasses) {
if (clazzName.contains(shadowsocksAndroidClass)) {
return "shadowsocks-android"
}
}
}
}
return null
}
}
private fun getVPNCoreType(packageInfo: PackageInfo): VPNCoreType? {
val packageFiles = mutableListOf(packageInfo.applicationInfo!!.publicSourceDir)
packageInfo.applicationInfo!!.splitPublicSourceDirs?.also {
packageFiles.addAll(it)
}
val vpnType =
try {
Libbox.readAndroidVPNType(packageFiles.toStringIterator())
} catch (ignored: Exception) {
return null
}
return VPNCoreType(vpnType.coreType, vpnType.corePath, vpnType.goVersion)
}
}

View File

@@ -1,294 +0,0 @@
package io.nekohasekai.sfa.ui.main
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding
import io.nekohasekai.sfa.databinding.SheetAddProfileBinding
import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.shareProfile
import io.nekohasekai.sfa.ktx.shareProfileURL
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.ui.profile.EditProfileActivity
import io.nekohasekai.sfa.ui.profile.NewProfileActivity
import io.nekohasekai.sfa.ui.profile.QRScanActivity
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Collections
class ConfigurationFragment : Fragment() {
private var adapter: Adapter? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentConfigurationBinding.inflate(inflater, container, false)
val adapter = Adapter(binding)
this.adapter = adapter
binding.profileList.also {
it.layoutManager = LinearLayoutManager(requireContext())
it.adapter = adapter
ItemTouchHelper(
object :
ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean {
return adapter.move(viewHolder.adapterPosition, target.adapterPosition)
}
override fun onSwiped(
viewHolder: RecyclerView.ViewHolder,
direction: Int,
) {
}
override fun onSelectedChanged(
viewHolder: RecyclerView.ViewHolder?,
actionState: Int,
) {
super.onSelectedChanged(viewHolder, actionState)
if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
adapter.updateUserOrder()
}
}
},
).attachToRecyclerView(it)
}
adapter.reload()
binding.fab.setOnClickListener {
AddProfileDialog().show(childFragmentManager, "add_profile")
}
ProfileManager.registerCallback(this::updateProfiles)
return binding.root
}
class AddProfileDialog : BottomSheetDialogFragment(R.layout.sheet_add_profile) {
private val importFromFile =
registerForActivityResult(ActivityResultContracts.GetContent(), ::onImportResult)
private val scanQrCode =
registerForActivityResult(QRScanActivity.Contract(), ::onScanResult)
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)
val binding = SheetAddProfileBinding.bind(view)
binding.importFromFile.setOnClickListener {
importFromFile.launch("*/*")
}
binding.scanQrCode.setOnClickListener {
scanQrCode.launch(null)
}
binding.createManually.setOnClickListener {
dismiss()
startActivity(Intent(requireContext(), NewProfileActivity::class.java))
}
}
private fun onImportResult(result: Uri?) {
dismiss()
(activity as? MainActivity ?: return).onNewIntent(Intent(Intent.ACTION_VIEW, result))
}
private fun onScanResult(result: Intent?) {
dismiss()
(activity as? MainActivity ?: return).onNewIntent(result ?: return)
}
}
override fun onResume() {
super.onResume()
adapter?.reload()
}
override fun onDestroyView() {
super.onDestroyView()
ProfileManager.unregisterCallback(this::updateProfiles)
adapter = null
}
private fun updateProfiles() {
adapter?.reload()
}
inner class Adapter(
private val parent: FragmentConfigurationBinding,
) :
RecyclerView.Adapter<Holder>() {
internal var items: MutableList<Profile> = mutableListOf()
internal val scope = lifecycleScope
internal val fragmentActivity = requireActivity()
@SuppressLint("NotifyDataSetChanged")
internal fun reload() {
lifecycleScope.launch(Dispatchers.IO) {
val newItems = ProfileManager.list().toMutableList()
withContext(Dispatchers.Main) {
items = newItems
notifyDataSetChanged()
if (items.isEmpty()) {
parent.statusText.isVisible = true
parent.profileList.isVisible = false
} else if (parent.statusText.isVisible) {
parent.statusText.isVisible = false
parent.profileList.isVisible = true
}
}
}
}
internal fun move(
from: Int,
to: Int,
): Boolean {
if (from < to) {
for (i in from until to) {
Collections.swap(items, i, i + 1)
}
} else {
for (i in from downTo to + 1) {
Collections.swap(items, i, i - 1)
}
}
notifyItemMoved(from, to)
return true
}
@OptIn(DelicateCoroutinesApi::class)
internal fun updateUserOrder() {
items.forEachIndexed { index, profile ->
profile.userOrder = index.toLong()
}
GlobalScope.launch(Dispatchers.IO) {
ProfileManager.update(items)
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): Holder {
return Holder(
this,
ViewConfigutationItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
),
)
}
override fun onBindViewHolder(
holder: Holder,
position: Int,
) {
holder.bind(items[position])
}
override fun getItemCount(): Int {
return items.size
}
}
class Holder(private val adapter: Adapter, private val binding: ViewConfigutationItemBinding) :
RecyclerView.ViewHolder(binding.root) {
internal fun bind(profile: Profile) {
binding.profileName.text = profile.name
if (profile.typed.type == TypedProfile.Type.Remote) {
binding.profileLastUpdated.isVisible = true
binding.profileLastUpdated.text =
binding.root.context.getString(
R.string.last_updated_format,
DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated),
)
} else {
binding.profileLastUpdated.isVisible = false
}
binding.root.setOnClickListener {
val intent = Intent(binding.root.context, EditProfileActivity::class.java)
intent.putExtra("profile_id", profile.id)
it.context.startActivity(intent)
}
binding.moreButton.setOnClickListener { button ->
val popup = PopupMenu(button.context, button)
popup.setForceShowIcon(true)
popup.menuInflater.inflate(R.menu.profile_menu, popup.menu)
if (profile.typed.type != TypedProfile.Type.Remote) {
popup.menu.removeItem(R.id.action_share_url)
}
popup.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_share -> {
adapter.scope.launch(Dispatchers.IO) {
try {
button.context.shareProfile(profile)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
button.context.errorDialogBuilder(e).show()
}
}
}
true
}
R.id.action_share_url -> {
adapter.scope.launch(Dispatchers.IO) {
try {
adapter.fragmentActivity.shareProfileURL(profile)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
button.context.errorDialogBuilder(e).show()
}
}
}
true
}
R.id.action_delete -> {
adapter.items.remove(profile)
adapter.notifyItemRemoved(adapterPosition)
adapter.scope.launch(Dispatchers.IO) {
runCatching {
ProfileManager.delete(profile)
}
}
true
}
else -> false
}
}
popup.show()
}
}
}
}

View File

@@ -1,187 +0,0 @@
package io.nekohasekai.sfa.ui.main
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayoutMediator
import io.nekohasekai.libbox.DeprecatedNoteIterator
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.databinding.FragmentDashboardBinding
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.launchCustomTab
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.ui.dashboard.GroupsFragment
import io.nekohasekai.sfa.ui.dashboard.OverviewFragment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DashboardFragment : Fragment(R.layout.fragment_dashboard) {
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
private var binding: FragmentDashboardBinding? = null
private var mediator: TabLayoutMediator? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentDashboardBinding.inflate(inflater, container, false)
this.binding = binding
onCreate()
return binding.root
}
private val adapter by lazy { Adapter(this) }
private fun onCreate() {
val activity = activity ?: return
val binding = binding ?: return
binding.dashboardPager.adapter = adapter
binding.dashboardPager.offscreenPageLimit = Page.values().size
activity.serviceStatus.observe(viewLifecycleOwner) {
when (it) {
Status.Stopped -> {
disablePager()
binding.fab.setImageResource(R.drawable.ic_play_arrow_24)
binding.fab.show()
binding.fab.isEnabled = true
}
Status.Starting -> {
binding.fab.hide()
}
Status.Started -> {
checkDeprecatedNotes()
enablePager()
binding.fab.setImageResource(R.drawable.ic_stop_24)
binding.fab.show()
binding.fab.isEnabled = true
}
Status.Stopping -> {
disablePager()
binding.fab.hide()
}
else -> {}
}
}
binding.fab.setOnClickListener {
when (activity.serviceStatus.value) {
Status.Stopped -> {
it.isEnabled = false
activity.startService()
}
Status.Started -> {
BoxService.stop()
}
else -> {}
}
}
}
override fun onStart() {
super.onStart()
val activityBinding = activity?.binding ?: return
val binding = binding ?: return
if (mediator != null) return
mediator =
TabLayoutMediator(
activityBinding.dashboardTabLayout,
binding.dashboardPager,
) { tab, position ->
tab.setText(Page.values()[position].titleRes)
}.apply { attach() }
}
override fun onDestroyView() {
super.onDestroyView()
mediator?.detach()
mediator = null
binding?.dashboardPager?.adapter = null
binding = null
}
private fun checkDeprecatedNotes() {
GlobalScope.launch(Dispatchers.IO) {
runCatching {
val notes = Libbox.newStandaloneCommandClient().deprecatedNotes
if (notes.hasNext()) {
withContext(Dispatchers.Main) {
loopShowDeprecatedNotes(notes)
}
}
}.onFailure {
withContext(Dispatchers.Main) {
activity?.errorDialogBuilder(it)?.show()
}
}
}
}
private fun loopShowDeprecatedNotes(notes: DeprecatedNoteIterator) {
if (notes.hasNext()) {
val note = notes.next()
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(getString(R.string.service_error_title_deprecated_warning))
builder.setMessage(note.message())
builder.setPositiveButton(R.string.ok) { _, _ ->
loopShowDeprecatedNotes(notes)
}
if (!note.migrationLink.isNullOrBlank()) {
builder.setNeutralButton(R.string.service_error_deprecated_warning_documentation) { _, _ ->
requireContext().launchCustomTab(note.migrationLink)
loopShowDeprecatedNotes(notes)
}
}
builder.show()
}
}
private fun enablePager() {
val activity = activity ?: return
val binding = binding ?: return
activity.binding.dashboardTabLayout.isVisible = true
binding.dashboardPager.isUserInputEnabled = true
}
private fun disablePager() {
val activity = activity ?: return
val binding = binding ?: return
activity.binding.dashboardTabLayout.isVisible = false
binding.dashboardPager.isUserInputEnabled = false
binding.dashboardPager.setCurrentItem(0, false)
}
enum class Page(
@StringRes val titleRes: Int,
val fragmentClass: Class<out Fragment>,
) {
Overview(R.string.title_overview, OverviewFragment::class.java),
Groups(R.string.title_groups, GroupsFragment::class.java),
}
class Adapter(parent: Fragment) : FragmentStateAdapter(parent) {
override fun getItemCount(): Int {
return Page.entries.size
}
override fun createFragment(position: Int): Fragment {
return Page.entries[position].fragmentClass.getConstructor().newInstance()
}
}
}

View File

@@ -1,198 +0,0 @@
package io.nekohasekai.sfa.ui.main
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.nekohasekai.libbox.LogEntry
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.databinding.FragmentLogBinding
import io.nekohasekai.sfa.databinding.ViewLogTextItemBinding
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.utils.ColorUtils
import io.nekohasekai.sfa.utils.CommandClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.LinkedList
class LogFragment : Fragment(), CommandClient.Handler {
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
private var binding: FragmentLogBinding? = null
private var adapter: Adapter? = null
private val commandClient =
CommandClient(lifecycleScope, CommandClient.ConnectionType.Log, this)
private val logList = LinkedList<String>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentLogBinding.inflate(inflater, container, false)
this.binding = binding
onCreate()
return binding.root
}
private fun onCreate() {
val activity = activity ?: return
val binding = binding ?: return
binding.logView.layoutManager = LinearLayoutManager(requireContext())
binding.logView.adapter = Adapter(logList).also { adapter = it }
updateViews()
activity.serviceStatus.observe(viewLifecycleOwner) {
when (it) {
Status.Stopped -> {
binding.fab.setImageResource(R.drawable.ic_play_arrow_24)
binding.fab.show()
binding.statusText.setText(R.string.status_default)
}
Status.Starting -> {
binding.fab.hide()
binding.statusText.setText(R.string.status_starting)
}
Status.Started -> {
commandClient.connect()
binding.fab.setImageResource(R.drawable.ic_stop_24)
binding.fab.show()
binding.fab.isEnabled = true
binding.statusText.setText(R.string.status_started)
}
Status.Stopping -> {
binding.fab.hide()
binding.statusText.setText(R.string.status_stopping)
}
else -> {}
}
}
binding.fab.setOnClickListener {
when (activity.serviceStatus.value) {
Status.Stopped -> {
it.isEnabled = false
activity.startService()
}
Status.Started -> {
BoxService.stop()
}
else -> {}
}
}
}
private fun updateViews(
removeLen: Int = 0,
insertLen: Int = 0,
) {
val activity = activity ?: return
val logAdapter = adapter ?: return
val binding = binding ?: return
if (logList.isEmpty()) {
binding.logView.isVisible = false
binding.statusText.isVisible = true
} else if (!binding.logView.isVisible) {
binding.logView.isVisible = true
binding.statusText.isVisible = false
}
if (insertLen == 0) {
logAdapter.notifyDataSetChanged()
if (logList.size > 0) {
binding.logView.scrollToPosition(logList.size - 1)
}
} else {
if (logList.size == 300) {
logAdapter.notifyItemRangeRemoved(0, removeLen)
}
logAdapter.notifyItemRangeInserted(logList.size - insertLen, insertLen)
binding.logView.scrollToPosition(logList.size - 1)
}
}
override fun onDestroyView() {
super.onDestroyView()
commandClient.disconnect()
binding = null
adapter = null
}
override fun onConnected() {
lifecycleScope.launch(Dispatchers.Main) {
logList.clear()
updateViews()
}
}
override fun clearLogs() {
lifecycleScope.launch(Dispatchers.Main) {
logList.clear()
updateViews()
}
}
private var defaultLogLevel = 0
override fun setDefaultLogLevel(level: Int) {
defaultLogLevel = level
}
override fun appendLogs(messageList: List<LogEntry>) {
val messageList = messageList.filter { it.level <= defaultLogLevel }
lifecycleScope.launch(Dispatchers.Main) {
val messageLen = messageList.size
val removeLen = logList.size + messageLen - 300
logList.addAll(messageList.map { it.message })
if (removeLen > 0) {
repeat(removeLen) {
logList.removeFirst()
}
}
updateViews(removeLen, messageLen)
}
}
class Adapter(private val logList: LinkedList<String>) :
RecyclerView.Adapter<LogViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): LogViewHolder {
return LogViewHolder(
ViewLogTextItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
),
)
}
override fun onBindViewHolder(
holder: LogViewHolder,
position: Int,
) {
holder.bind(logList.getOrElse(position) { "" })
}
override fun getItemCount(): Int {
return logList.size
}
}
class LogViewHolder(private val binding: ViewLogTextItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(message: String) {
binding.text.text = ColorUtils.ansiEscapeToSpannable(binding.root.context, message)
}
}
}

View File

@@ -1,173 +0,0 @@
package io.nekohasekai.sfa.ui.main
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.constant.EnabledType
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.databinding.FragmentSettingsBinding
import io.nekohasekai.sfa.ktx.addTextChangedListener
import io.nekohasekai.sfa.ktx.launchCustomTab
import io.nekohasekai.sfa.ktx.setSimpleItems
import io.nekohasekai.sfa.ktx.text
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.ui.debug.DebugActivity
import io.nekohasekai.sfa.ui.profileoverride.ProfileOverrideActivity
import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SettingsFragment : Fragment() {
private lateinit var binding: FragmentSettingsBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
binding = FragmentSettingsBinding.inflate(inflater, container, false)
onCreate()
return binding.root
}
@RequiresApi(Build.VERSION_CODES.M)
private val requestIgnoreBatteryOptimizations =
registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)) {
binding.backgroundPermissionCard.isGone = true
}
}
@SuppressLint("BatteryLife")
private fun onCreate() {
val activity = activity as MainActivity? ?: return
val binding = binding ?: return
binding.versionText.text = Libbox.version()
binding.clearButton.setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) {
activity.getExternalFilesDir(null)?.deleteRecursively()
reloadSettings()
}
}
binding.useComposeUIEnabled.addTextChangedListener {
lifecycleScope.launch(Dispatchers.IO) {
val newValue = EnabledType.valueOf(requireContext(), it).boolValue
Settings.useComposeUI = newValue
if (newValue) {
withContext(Dispatchers.Main) {
// Restart with Compose UI
val intent = Intent(requireContext(), Class.forName("io.nekohasekai.sfa.compose.ComposeActivity"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
activity.finish()
}
}
}
}
binding.checkUpdateEnabled.addTextChangedListener {
lifecycleScope.launch(Dispatchers.IO) {
val newValue = EnabledType.valueOf(requireContext(), it).boolValue
Settings.checkUpdateEnabled = newValue
}
}
binding.checkUpdateButton.setOnClickListener {
Vendor.checkUpdate(activity, true)
}
binding.openPrivacyPolicyButton.setOnClickListener {
activity.launchCustomTab("https://sing-box.sagernet.org/clients/privacy/")
}
binding.disableMemoryLimit.addTextChangedListener {
lifecycleScope.launch(Dispatchers.IO) {
val newValue = EnabledType.valueOf(requireContext(), it).boolValue
Settings.disableMemoryLimit = !newValue
}
}
binding.dynamicNotificationEnabled.addTextChangedListener {
lifecycleScope.launch(Dispatchers.IO) {
val newValue = EnabledType.valueOf(requireContext(), it).boolValue
Settings.dynamicNotification = newValue
}
}
binding.dontKillMyAppButton.setOnClickListener {
it.context.launchCustomTab("https://dontkillmyapp.com/")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.requestIgnoreBatteryOptimizationsButton.setOnClickListener {
requestIgnoreBatteryOptimizations.launch(
Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${Application.application.packageName}"),
),
)
}
}
binding.configureOverridesButton.setOnClickListener {
startActivity(Intent(requireContext(), ProfileOverrideActivity::class.java))
}
binding.openDebugButton.setOnClickListener {
startActivity(Intent(requireContext(), DebugActivity::class.java))
}
binding.startSponserButton.setOnClickListener {
activity.launchCustomTab("https://sekai.icu/sponsors/")
}
lifecycleScope.launch(Dispatchers.IO) {
reloadSettings()
}
}
private suspend fun reloadSettings() {
val activity = activity ?: return
val binding = binding ?: return
val dataSize =
Libbox.formatBytes(
(activity.getExternalFilesDir(null) ?: activity.filesDir)
.walkTopDown().filter { it.isFile }.map { it.length() }.sum(),
)
val checkUpdateEnabled = Settings.checkUpdateEnabled
val removeBackgroundPermissionPage =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)
} else {
true
}
val dynamicNotification = Settings.dynamicNotification
val useComposeUI = Settings.useComposeUI
withContext(Dispatchers.Main) {
binding.dataSizeText.text = dataSize
binding.checkUpdateEnabled.text =
EnabledType.from(checkUpdateEnabled).getString(requireContext())
binding.checkUpdateEnabled.setSimpleItems(R.array.enabled)
binding.disableMemoryLimit.text =
EnabledType.from(!Settings.disableMemoryLimit).getString(requireContext())
binding.disableMemoryLimit.setSimpleItems(R.array.enabled)
binding.backgroundPermissionCard.isGone = removeBackgroundPermissionPage
binding.dynamicNotificationEnabled.text =
EnabledType.from(dynamicNotification).getString(requireContext())
binding.dynamicNotificationEnabled.setSimpleItems(R.array.enabled)
binding.experimentalFeaturesCard.isVisible = BuildConfig.DEBUG
binding.useComposeUIEnabled.text =
EnabledType.from(useComposeUI).getString(requireContext())
binding.useComposeUIEnabled.setSimpleItems(R.array.enabled)
}
}
}

View File

@@ -1,203 +0,0 @@
package io.nekohasekai.sfa.ui.profile
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.UpdateProfileWork
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
import io.nekohasekai.sfa.constant.EnabledType
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.databinding.ActivityEditProfileBinding
import io.nekohasekai.sfa.ktx.addTextChangedListener
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.setSimpleItems
import io.nekohasekai.sfa.ktx.text
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import io.nekohasekai.sfa.utils.HTTPClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Date
class EditProfileActivity : AbstractActivity<ActivityEditProfileBinding>() {
private lateinit var profile: Profile
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_edit_profile)
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
loadProfile()
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it)
.setPositiveButton(R.string.ok) { _, _ -> finish() }
.show()
}
}
}
}
private suspend fun loadProfile() {
delay(200L)
val profileId = intent.getLongExtra("profile_id", -1L)
if (profileId == -1L) error("invalid arguments")
profile = ProfileManager.get(profileId) ?: error("invalid arguments")
withContext(Dispatchers.Main) {
binding.name.text = profile.name
binding.name.addTextChangedListener {
lifecycleScope.launch(Dispatchers.IO) {
try {
profile.name = it
ProfileManager.update(profile)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
errorDialogBuilder(e).show()
}
}
}
}
binding.type.text = profile.typed.type.getString(this@EditProfileActivity)
binding.editButton.setOnClickListener {
startActivity(
Intent(
this@EditProfileActivity,
EditProfileContentActivity::class.java,
).apply {
putExtra("profile_id", profile.id)
},
)
}
when (profile.typed.type) {
TypedProfile.Type.Local -> {
binding.editButton.isVisible = true
binding.remoteFields.isVisible = false
}
TypedProfile.Type.Remote -> {
binding.editButton.isVisible = false
binding.remoteFields.isVisible = true
binding.remoteURL.text = profile.typed.remoteURL
binding.lastUpdated.text =
RelativeTimeFormatter.format(this@EditProfileActivity, profile.typed.lastUpdated)
binding.autoUpdate.text =
EnabledType.from(profile.typed.autoUpdate)
.getString(this@EditProfileActivity)
binding.autoUpdate.setSimpleItems(R.array.enabled)
binding.autoUpdateInterval.isVisible = profile.typed.autoUpdate
binding.autoUpdateInterval.text = profile.typed.autoUpdateInterval.toString()
}
}
binding.remoteURL.addTextChangedListener(this@EditProfileActivity::updateRemoteURL)
binding.autoUpdate.addTextChangedListener(this@EditProfileActivity::updateAutoUpdate)
binding.autoUpdateInterval.addTextChangedListener(this@EditProfileActivity::updateAutoUpdateInterval)
binding.updateButton.setOnClickListener(this@EditProfileActivity::updateProfile)
binding.profileLayout.isVisible = true
binding.progressView.isVisible = false
}
}
private fun updateRemoteURL(newValue: String) {
profile.typed.remoteURL = newValue
updateProfile()
}
private fun updateAutoUpdate(newValue: String) {
val boolValue = EnabledType.valueOf(this, newValue).boolValue
if (profile.typed.autoUpdate == boolValue) {
return
}
binding.autoUpdateInterval.isVisible = boolValue
profile.typed.autoUpdate = boolValue
if (boolValue) {
lifecycleScope.launch(Dispatchers.IO) {
UpdateProfileWork.reconfigureUpdater()
}
}
updateProfile()
}
private fun updateAutoUpdateInterval(newValue: String) {
if (newValue.isBlank()) {
binding.autoUpdateInterval.error = getString(R.string.profile_input_required)
return
}
val intValue =
try {
newValue.toInt()
} catch (e: Exception) {
binding.autoUpdateInterval.error = e.localizedMessage
return
}
if (intValue < 15) {
binding.autoUpdateInterval.error =
getString(R.string.profile_auto_update_interval_minimum_hint)
return
}
binding.autoUpdateInterval.error = null
profile.typed.autoUpdateInterval = intValue
updateProfile()
}
private fun updateProfile() {
binding.progressView.isVisible = true
lifecycleScope.launch(Dispatchers.IO) {
delay(200L)
try {
ProfileManager.update(profile)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
errorDialogBuilder(e).show()
}
}
withContext(Dispatchers.Main) {
binding.progressView.isVisible = false
}
}
}
@Suppress("UNUSED_PARAMETER")
private fun updateProfile(view: View) {
binding.progressView.isVisible = true
lifecycleScope.launch(Dispatchers.IO) {
var selectedProfileUpdated = false
try {
val content = HTTPClient().use { it.getString(profile.typed.remoteURL) }
Libbox.checkConfig(content)
val file = File(profile.typed.path)
if (file.readText() != content) {
File(profile.typed.path).writeText(content)
if (profile.id == Settings.selectedProfile) {
selectedProfileUpdated = true
}
}
profile.typed.lastUpdated = Date()
ProfileManager.update(profile)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
errorDialogBuilder(e).show()
}
}
withContext(Dispatchers.Main) {
binding.lastUpdated.text =
RelativeTimeFormatter.format(this@EditProfileActivity, profile.typed.lastUpdated)
binding.progressView.isVisible = false
}
if (selectedProfileUpdated) {
runCatching {
Libbox.newStandaloneCommandClient().serviceReload()
}
}
}
}
}

View File

@@ -1,138 +0,0 @@
package io.nekohasekai.sfa.ui.profile
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import com.blacksquircle.ui.language.json.JsonLanguage
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.databinding.ActivityEditProfileContentBinding
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.unwrap
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
class EditProfileContentActivity : AbstractActivity<ActivityEditProfileContentBinding>() {
private var profile: Profile? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_edit_configuration)
binding.editor.language = JsonLanguage()
loadConfiguration()
}
private fun loadConfiguration() {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
loadConfiguration0()
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it)
.setPositiveButton(R.string.ok) { _, _ -> finish() }
.show()
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.edit_configutation_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_undo -> {
if (binding.editor.canUndo()) binding.editor.undo()
return true
}
R.id.action_redo -> {
if (binding.editor.canRedo()) binding.editor.redo()
return true
}
R.id.action_check -> {
binding.progressView.isVisible = true
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
Libbox.checkConfig(binding.editor.text.toString())
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it).show()
}
}
withContext(Dispatchers.Main) {
delay(200)
binding.progressView.isInvisible = true
}
}
return true
}
R.id.action_format -> {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val content = Libbox.formatConfig(binding.editor.text.toString()).unwrap
if (binding.editor.text.toString() != content) {
withContext(Dispatchers.Main) {
binding.editor.setTextContent(content)
}
}
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it).show()
}
}
}
return true
}
}
return super.onOptionsItemSelected(item)
}
private suspend fun loadConfiguration0() {
delay(200L)
val profileId = intent.getLongExtra("profile_id", -1L)
if (profileId == -1L) error("invalid arguments")
val profile = ProfileManager.get(profileId) ?: error("invalid arguments")
this.profile = profile
val content = File(profile.typed.path).readText()
withContext(Dispatchers.Main) {
binding.editor.setTextContent(content)
binding.editor.addTextChangedListener {
binding.progressView.isVisible = true
val newContent = it.toString()
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
File(profile.typed.path).writeText(newContent)
}.onFailure {
withContext(Dispatchers.Main) {
errorDialogBuilder(it)
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
.show()
}
}
withContext(Dispatchers.Main) {
delay(200L)
binding.progressView.isInvisible = true
}
}
}
binding.progressView.isInvisible = true
}
}
}

View File

@@ -1,221 +0,0 @@
package io.nekohasekai.sfa.ui.profile
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.constant.EnabledType
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.databinding.ActivityAddProfileBinding
import io.nekohasekai.sfa.ktx.addTextChangedListener
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.removeErrorIfNotEmpty
import io.nekohasekai.sfa.ktx.showErrorIfEmpty
import io.nekohasekai.sfa.ktx.startFilesForResult
import io.nekohasekai.sfa.ktx.text
import io.nekohasekai.sfa.ui.shared.AbstractActivity
import io.nekohasekai.sfa.utils.HTTPClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.util.Date
class NewProfileActivity : AbstractActivity<ActivityAddProfileBinding>() {
enum class FileSource(
@StringRes val formattedRes: Int,
) {
CreateNew(R.string.profile_source_create_new),
Import(R.string.profile_source_import),
;
fun formatted(context: Context): String {
return context.getString(formattedRes)
}
}
private val importFile =
registerForActivityResult(ActivityResultContracts.GetContent()) { fileURI ->
if (fileURI != null) {
binding.sourceURL.editText?.setText(fileURI.toString())
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.title_new_profile)
intent.getStringExtra("importName")?.also { importName ->
intent.getStringExtra("importURL")?.also { importURL ->
binding.name.editText?.setText(importName)
binding.type.text = TypedProfile.Type.Remote.getString(this)
binding.remoteURL.editText?.setText(importURL)
binding.localFields.isVisible = false
binding.remoteFields.isVisible = true
binding.autoUpdateInterval.text = "60"
}
}
binding.name.removeErrorIfNotEmpty()
binding.type.addTextChangedListener {
when (it) {
TypedProfile.Type.Local.getString(this) -> {
binding.localFields.isVisible = true
binding.remoteFields.isVisible = false
}
TypedProfile.Type.Remote.getString(this) -> {
binding.localFields.isVisible = false
binding.remoteFields.isVisible = true
if (binding.autoUpdateInterval.text.toIntOrNull() == null) {
binding.autoUpdateInterval.text = "60"
}
}
}
}
binding.fileSourceMenu.addTextChangedListener {
when (it) {
FileSource.CreateNew.formatted(this) -> {
binding.importFileButton.isVisible = false
binding.sourceURL.isVisible = false
}
FileSource.Import.formatted(this) -> {
binding.importFileButton.isVisible = true
binding.sourceURL.isVisible = true
}
}
}
binding.importFileButton.setOnClickListener {
startFilesForResult(importFile, "application/json")
}
binding.createProfile.setOnClickListener(this::createProfile)
binding.autoUpdateInterval.addTextChangedListener(this::updateAutoUpdateInterval)
}
private fun createProfile(
@Suppress("UNUSED_PARAMETER") view: View,
) {
if (binding.name.showErrorIfEmpty()) {
return
}
when (binding.type.text) {
TypedProfile.Type.Local.getString(this) -> {
when (binding.fileSourceMenu.text) {
FileSource.Import.formatted(this) -> {
if (binding.sourceURL.showErrorIfEmpty()) {
return
}
}
}
}
TypedProfile.Type.Remote.getString(this) -> {
if (binding.remoteURL.showErrorIfEmpty()) {
return
}
}
}
binding.progressView.isVisible = true
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
createProfile0()
}.onFailure { e ->
withContext(Dispatchers.Main) {
binding.progressView.isVisible = false
errorDialogBuilder(e).show()
}
}
}
}
private suspend fun createProfile0() {
val typedProfile = TypedProfile()
val profile = Profile(name = binding.name.text, typed = typedProfile)
profile.userOrder = ProfileManager.nextOrder()
val fileID = ProfileManager.nextFileID()
val configDirectory = File(filesDir, "configs").also { it.mkdirs() }
val configFile = File(configDirectory, "$fileID.json")
typedProfile.path = configFile.path
when (binding.type.text) {
TypedProfile.Type.Local.getString(this) -> {
typedProfile.type = TypedProfile.Type.Local
when (binding.fileSourceMenu.text) {
FileSource.CreateNew.formatted(this) -> {
configFile.writeText("{}")
}
FileSource.Import.formatted(this) -> {
val sourceURL = binding.sourceURL.text
val content =
if (sourceURL.startsWith("content://")) {
val inputStream =
contentResolver.openInputStream(Uri.parse(sourceURL)) as InputStream
inputStream.use { it.bufferedReader().readText() }
} else if (sourceURL.startsWith("file://")) {
File(sourceURL).readText()
} else if (sourceURL.startsWith("http://") || sourceURL.startsWith("https://")) {
HTTPClient().use { it.getString(sourceURL) }
} else {
error("unsupported source: $sourceURL")
}
Libbox.checkConfig(content)
configFile.writeText(content)
}
}
}
TypedProfile.Type.Remote.getString(this) -> {
typedProfile.type = TypedProfile.Type.Remote
val remoteURL = binding.remoteURL.text
val content = HTTPClient().use { it.getString(remoteURL) }
Libbox.checkConfig(content)
configFile.writeText(content)
typedProfile.remoteURL = remoteURL
typedProfile.lastUpdated = Date()
typedProfile.autoUpdate =
EnabledType.valueOf(this, binding.autoUpdate.text).boolValue
binding.autoUpdateInterval.text.toIntOrNull()?.also {
typedProfile.autoUpdateInterval = it
}
}
}
ProfileManager.create(profile)
withContext(Dispatchers.Main) {
binding.progressView.isVisible = false
finish()
}
}
private fun updateAutoUpdateInterval(newValue: String) {
if (newValue.isBlank()) {
binding.autoUpdateInterval.error = getString(R.string.profile_input_required)
return
}
val intValue =
try {
newValue.toInt()
} catch (e: Exception) {
binding.autoUpdateInterval.error = e.localizedMessage
return
}
if (intValue < 15) {
binding.autoUpdateInterval.error =
getString(R.string.profile_auto_update_interval_minimum_hint)
return
}
binding.autoUpdateInterval.error = null
}
}

View File

@@ -1,27 +0,0 @@
package io.nekohasekai.sfa.ui.profileoverride
import android.content.Intent
import android.os.Bundle
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.databinding.ActivityConfigOverrideBinding
import io.nekohasekai.sfa.ui.shared.AbstractActivity
class ProfileOverrideActivity :
AbstractActivity<ActivityConfigOverrideBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.profile_override)
binding.switchPerAppProxy.isChecked = Settings.perAppProxyEnabled
binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked ->
Settings.perAppProxyEnabled = isChecked
binding.configureAppListButton.isEnabled = isChecked
}
binding.configureAppListButton.isEnabled = binding.switchPerAppProxy.isChecked
binding.configureAppListButton.setOnClickListener {
startActivity(Intent(this, PerAppProxyActivity::class.java))
}
}
}

View File

@@ -14,7 +14,6 @@ import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.color.DynamicColors
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.ktx.getAttrColor
import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.utils.MIUIUtils
import java.lang.reflect.ParameterizedType
@@ -56,17 +55,15 @@ abstract class AbstractActivity<Binding : ViewBinding> : AppCompatActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
}
if (this !is MainActivity) {
supportActionBar?.setHomeAsUpIndicator(
AppCompatResources.getDrawable(
this@AbstractActivity,
R.drawable.ic_arrow_back_24,
)!!.apply {
setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface))
},
)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
supportActionBar?.setHomeAsUpIndicator(
AppCompatResources.getDrawable(
this@AbstractActivity,
R.drawable.ic_arrow_back_24,
)!!.apply {
setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface))
},
)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {

View File

@@ -1,25 +0,0 @@
package io.nekohasekai.sfa.ui.shared
import android.graphics.Bitmap
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.nekohasekai.sfa.databinding.FragmentQrcodeDialogBinding
class QRCodeDialog(private val bitmap: Bitmap) :
BottomSheetDialogFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentQrcodeDialogBinding.inflate(inflater, container, false)
val behavior = BottomSheetBehavior.from(binding.qrcodeLayout)
behavior.state = BottomSheetBehavior.STATE_EXPANDED
binding.qrCode.setImageBitmap(bitmap)
return binding.root
}
}