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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user